├── .cursor └── rules │ ├── 0-meta-features.mdc │ ├── 0-meta-rules.mdc │ ├── 1-helper-prd.mdc │ └── api-routes.mdc ├── .devcontainer └── devcontainer.json ├── .env.example ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── release.yaml │ └── validate.yaml ├── .gitignore ├── .release-please-manifest.json ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── api ├── package.json ├── src │ ├── index.ts │ ├── monitor-exec.ts │ └── monitor-trigger.ts ├── wrangler-monitor-exec.jsonc └── wrangler-monitor-trigger.jsonc ├── biome.jsonc ├── cloudflare-env-gen.d.ts ├── cloudflare-env.d.ts ├── components.json ├── docs └── dashboard-demo.gif ├── drizzle.config.ts ├── next.config.ts ├── open-next.config.ts ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── postcss.config.mjs ├── public ├── apple-touch-icon.png ├── icon-192.png ├── icon-512.png └── og_image.png ├── registry.json ├── release-please-config.json ├── scripts ├── bootstrap.sh └── log-opennext-bundle.js ├── src ├── app │ ├── api │ │ └── endpoint-monitors │ │ │ ├── [id] │ │ │ ├── checks │ │ │ │ ├── history │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── execute-check │ │ │ │ └── route.ts │ │ │ ├── init-do │ │ │ │ └── route.ts │ │ │ ├── pause │ │ │ │ └── route.ts │ │ │ ├── resume │ │ │ │ └── route.ts │ │ │ ├── route.ts │ │ │ ├── status │ │ │ │ └── route.ts │ │ │ └── uptime │ │ │ │ ├── limit │ │ │ │ └── route.ts │ │ │ │ ├── range │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── count │ │ │ └── route.ts │ │ │ ├── route.ts │ │ │ └── stats │ │ │ └── route.ts │ ├── endpoint-monitors │ │ └── [id] │ │ │ └── page.tsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── page.tsx │ ├── site.ts │ ├── theme.css │ └── themes.css ├── components │ ├── active-theme.tsx │ ├── add-endpoint-monitor-dialog.tsx │ ├── app-sidebar.tsx │ ├── bg-patterns │ │ ├── README.md │ │ ├── diagonal-stripes.tsx │ │ ├── polka-dots.tsx │ │ └── waves.tsx │ ├── chart-area-interactive.tsx │ ├── data-table │ │ ├── column-header.tsx │ │ ├── columns.tsx │ │ ├── data-row.tsx │ │ ├── data-table-loading-overlay.tsx │ │ ├── data-table-skeleton.tsx │ │ ├── data-table.tsx │ │ ├── endpoint-monitor-actions.tsx │ │ ├── endpoint-monitor-detail-drawer.tsx │ │ ├── index.tsx │ │ ├── pagination.tsx │ │ └── toolbar.tsx │ ├── endpoint-monitor-detail-header.tsx │ ├── endpoint-monitor-section-cards.tsx │ ├── icons │ │ └── solstatus-logo.tsx │ ├── latency-limit-chart.tsx │ ├── latency-range-chart.tsx │ ├── mode-toggle.tsx │ ├── nav-documents.tsx │ ├── nav-main.tsx │ ├── nav-secondary.tsx │ ├── nav-user.tsx │ ├── section-cards.tsx │ ├── site-header.tsx │ ├── theme-provider.tsx │ ├── theme-selector.tsx │ └── uptime-chart.tsx ├── context │ └── header-context.tsx ├── db │ ├── index.ts │ ├── migrations │ │ ├── 0000_fixed_quicksilver.sql │ │ ├── 0001_silent_cyclops.sql │ │ ├── 0002_strange_oracle.sql │ │ ├── 0003_young_marvel_boy.sql │ │ ├── 0004_warm_lady_bullseye.sql │ │ ├── 0005_strange-cyclops.sql │ │ ├── 0006_rich_brother_voodoo.sql │ │ ├── 0007_young_sue_storm.sql │ │ ├── 0008_common_psylocke.sql │ │ ├── 0009_many_raza.sql │ │ ├── 0010_dashing_major_mapleleaf.sql │ │ ├── 0011_smiling_cassandra_nova.sql │ │ └── meta │ │ │ ├── 0000_snapshot.json │ │ │ ├── 0001_snapshot.json │ │ │ ├── 0002_snapshot.json │ │ │ ├── 0003_snapshot.json │ │ │ ├── 0004_snapshot.json │ │ │ ├── 0005_snapshot.json │ │ │ ├── 0006_snapshot.json │ │ │ ├── 0007_snapshot.json │ │ │ ├── 0008_snapshot.json │ │ │ ├── 0009_snapshot.json │ │ │ ├── 0010_snapshot.json │ │ │ ├── 0011_snapshot.json │ │ │ └── _journal.json │ ├── schema │ │ ├── endpointMonitor.ts │ │ ├── index.ts │ │ └── utils.ts │ └── zod-schema.ts ├── fonts │ ├── GeistMonoVF.woff │ └── GeistVF.woff ├── hooks │ └── use-mobile.ts ├── lib │ ├── api-utils.ts │ ├── app-env.ts │ ├── constants.ts │ ├── errors.ts │ ├── fonts.ts │ ├── formatters.ts │ ├── ids.ts │ ├── opsgenie.ts │ ├── route-schemas.ts │ ├── toasts.ts │ ├── uptime-utils.ts │ └── utils.ts ├── registry │ └── new-york-v4 │ │ ├── hooks │ │ └── use-mobile.ts │ │ ├── lib │ │ └── utils.ts │ │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ └── tooltip.tsx ├── store │ ├── dashboard-stats-store.ts │ └── data-table-store.ts └── types │ └── endpointMonitor.ts ├── tailwind.config.cjs ├── test └── seed.ts ├── tsconfig.json └── wrangler.jsonc /.cursor/rules/0-meta-features.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | --- 7 | description: How to create rules for project features, including feature summaries and related files by usage type 8 | globs: 9 | alwaysApply: false 10 | --- 11 | # Project Feature Rule Creation 12 | 13 | How to create new rules for project features in our project 14 | 15 | 1. Always place feature rule files in PROJECT_ROOT/.cursor/rules/: 16 | ``` 17 | .cursor/rules/ 18 | ├── feature-login-authentication.mdc 19 | ├── feature-user-profile.mdc 20 | └── ... 21 | ``` 22 | 23 | 2. Follow the naming convention: 24 | - Use kebab-case for filenames 25 | - Prefix with "feature-" followed by a descriptive name 26 | - Always use .mdc extension (e.g., `feature-login-authentication.mdc`) 27 | 28 | 3. Directory structure: 29 | ``` 30 | PROJECT_ROOT/ 31 | ├── .cursor/ 32 | │ └── rules/ 33 | │ ├── feature-login-authentication.mdc 34 | │ └── ... 35 | └── ... 36 | ``` 37 | 38 | 4. Never place feature rule files: 39 | - In the project root 40 | - In subdirectories outside .cursor/rules 41 | - In any other location 42 | 43 | 5. Feature rules have the following structure: 44 | - Start with a summary of the feature 45 | - Include sections for related files grouped by usage type (e.g., routes, UI components and pages, DB schemas) 46 | - Provide examples of implementation or usage 47 | 48 | Example: 49 | ``` 50 | --- 51 | description: Guidelines for implementing and improving the user auth feature 52 | globs: 53 | alwaysApply: false 54 | --- 55 | # User Auth Feature Rule 56 | 57 | ## Feature Summary 58 | The auth feature enables users to securely access their accounts in the application. 59 | It supports multiple authentication methods including email/password, OAuth, and two-factor authentication. 60 | It works by validating credentials and generating secure tokens for authorized sessions. 61 | It uses a combination of standard security protocols and custom validation rules. 62 | It uses both Cloudflare D1 SQLite (Drizzle) for persistent user data and Cloudflare KV for session management. 63 | We store short-lived session tokens in Cloudflare KV that expire after a few hours. This includes minimal user identifiers needed for authentication while keeping sensitive data in the secure database, balancing security with performance. 64 | Once authenticated, users receive a secure JWT token that's validated with each subsequent request to protected resources. 65 | 66 | ## Related Files by Usage Type 67 | 68 | ### Routes 69 | - `src/routes/auth.ts`: Defines login endpoints (e.g., POST /login) 70 | - `src/routes/index.ts`: Entry point registering auth routes 71 | 72 | ### UI Components and Pages 73 | - `src/components/LoginForm.tsx`: Reusable login form component 74 | - `src/pages/LoginPage.tsx`: Main login page assembling the form and handling submission 75 | 76 | ### DB Schemas 77 | - `src/db/schemas/users.ts`: User table schema with fields like email, password_hash, and role 78 | - `src/db/schemas/sessions.ts`: Session table for tracking active logins 79 | 80 | ## Implementation Guidelines 81 | 1. Use secure password hashing (e.g., bcrypt) in the backend 82 | 2. Validate inputs on both client and server sides 83 | 3. Handle errors with user-friendly messages 84 | ``` 85 | -------------------------------------------------------------------------------- /.cursor/rules/0-meta-rules.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: How to add or edit Cursor rules in our project 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # Cursor Rules Location and Conventions 7 | 8 | This document outlines how to add, edit, and categorize Cursor rules within this project. 9 | 10 | ## How to use in Cursor AI 11 | 12 | To get help on how to use this meta rule in Cursor, reference the rule by starting with `@` followed by the rule filename (without the `.mdc` extension). For example: 13 | ``` 14 | @0-meta-rules.mdc Tell me how to use this rule. 15 | ``` 16 | 17 | To use this meta rule in Cursor to create a new rule or update a new rule, reference it like above and add context for the rule. For example: 18 | ``` 19 | @0-meta-rules.mdc create a api-routes.mdc rule for how routes should be created based our current project usage. Be sure to include schema validation, stoker usage, cloudflare context, createRoute util, JSDoc for OpenAPI, error handling, caching behavior if needed, tools and frameworks to use, and any other common patterns you notice. 20 | ``` 21 | 22 | This command will: 23 | 1. Reference this meta-rule to follow proper rule creation guidelines 24 | 2. Create a new rule file named `api-routes.mdc` in the `.cursor/rules/` directory 25 | 3. Structure it according to our conventions 26 | 4. Include the requested API routing patterns and standards 27 | 28 | ## Rule Location and Naming 29 | 1. Always place rule files in `PROJECT_ROOT/.cursor/rules/`: 30 | ``` 31 | .cursor/rules/ 32 | ├── 0-meta-rules.mdc 33 | ├── 1-helper-prd.mdc 34 | ├── feature-user-profile.mdc 35 | └── ... 36 | ``` 37 | 2. Follow the naming convention: 38 | - Use kebab-case for filenames. 39 | - Always use the `.mdc` extension. 40 | - Use prefixes to indicate the rule type (see Rule Categories below). 41 | - Make names descriptive of the rule's purpose. 42 | 43 | 3. Directory structure: 44 | ``` 45 | PROJECT_ROOT/ 46 | ├── .cursor/ 47 | │ └── rules/ 48 | │ ├── 0-meta-rules.mdc 49 | │ ├── 1-helper-prd.mdc 50 | │ ├── feature-login-authentication.mdc 51 | │ └── ... 52 | └── ... 53 | ``` 54 | 55 | 4. Never place rule files: 56 | - In the project root. 57 | - In subdirectories outside `.cursor/rules`. 58 | - In any other location. 59 | 60 | ## Rule Categories 61 | 62 | We use prefixes to categorize rules based on their purpose: 63 | 64 | 1. **`0-meta-*` Rules:** 65 | * **Purpose:** These rules define *how to create and manage other rules*. They focus on the structure, naming conventions, and processes related to rule management itself. 66 | * **Example:** `0-meta-rules.mdc` (this file), `0-meta-features.mdc`. 67 | * **Nature:** Generally not codebase-specific; they describe project conventions for using Cursor rules. 68 | 69 | 2. **`1-helper-*` Rules:** 70 | * **Purpose:** These rules provide general-purpose assistance for tasks like ideation, documentation generation (e.g., PRDs), or applying specific development methodologies. 71 | * **Example:** `1-helper-prd.mdc`. 72 | * **Nature:** Usually not codebase-specific, though they might reference `@codebase` for context during execution. They aim to assist the developer in broader tasks. 73 | 74 | 3. **`feature-*` Rules:** (Described in `0-meta-features.mdc`) 75 | * **Purpose:** These rules encapsulate knowledge about specific features within the codebase, linking feature summaries to relevant files (routes, UI components, schemas, etc.). 76 | * **Example:** `feature-login-authentication.mdc`. 77 | * **Nature:** Highly codebase-specific. 78 | 79 | ## Rule Structure 80 | 81 | Cursor rules generally have the following structure: 82 | 83 | ```markdown 84 | --- 85 | description: Short description of the rule's purpose 86 | globs: optional/path/pattern/**/* # Optional: Limit rule application to specific files/directories 87 | alwaysApply: false # Typically false unless the rule should always be active 88 | --- 89 | # Rule Title 90 | 91 | Main content explaining the rule with markdown formatting. 92 | 93 | 1. How to use in Cursor AI 94 | 2. Step-by-step instructions 95 | 3. Code examples 96 | 4. Guidelines 97 | 5. Definitions or explanations of concepts 98 | ``` 99 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/devcontainers/typescript-node:22", 3 | "postStartCommand": "scripts/bootstrap.sh", 4 | "customizations": { 5 | "vscode": { 6 | "extensions": [ 7 | "redhat.vscode-yaml", 8 | "GitHub.vscode-pull-request-github", 9 | "GitHub.copilot", 10 | "formulahendry.auto-close-tag", 11 | "formulahendry.auto-rename-tag", 12 | "biomejs.biome", 13 | "cloudflare.cloudflare-workers-bindings-extension", 14 | "streetsidesoftware.code-spell-checker", 15 | "mikestead.dotenv", 16 | "usernamehw.errorlens", 17 | "cyberBabushkin.filesize-hover-explorer", 18 | "mhutchie.git-graph", 19 | "github.vscode-github-actions", 20 | "GitHub.github-vscode-theme", 21 | "k--kato.intellij-idea-keybindings", 22 | "YoavBls.pretty-ts-errors", 23 | "bradlc.vscode-tailwindcss" 24 | ] 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ######## wrangler cli env vars 2 | 3 | # https://developers.cloudflare.com/workers/wrangler/system-environment-variables/ 4 | CLOUDFLARE_ACCOUNT_ID="" 5 | 6 | # Create via "Edit Cloudflare Workers" template + Account:D1:edit 7 | CLOUDFLARE_API_TOKEN="" 8 | WRANGLER_SEND_METRICS=false 9 | 10 | NEXT_TELEMETRY_DISABLED=1 11 | 12 | ######## App and api env vars 13 | 14 | APP_ENV="development" 15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: unibeck 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Main package.json for npm dependencies 4 | - package-ecosystem: "npm" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "monday" 9 | open-pull-requests-limit: 10 10 | versioning-strategy: increase 11 | groups: 12 | minor-and-patch-dependencies: 13 | patterns: 14 | - "*" 15 | update-types: 16 | - "minor" 17 | - "patch" 18 | commit-message: 19 | prefix: "deps" 20 | include: "scope" 21 | ignore: 22 | # Ignore major version updates to React which would need more careful testing 23 | - dependency-name: "react*" 24 | update-types: ["version-update:semver-major"] 25 | - dependency-name: "@types/react*" 26 | update-types: ["version-update:semver-major"] 27 | 28 | # API directory package.json for npm dependencies 29 | - package-ecosystem: "npm" 30 | directory: "/api" 31 | schedule: 32 | interval: "weekly" 33 | day: "monday" 34 | open-pull-requests-limit: 5 35 | versioning-strategy: increase 36 | groups: 37 | api-dependencies: 38 | patterns: 39 | - "*" 40 | commit-message: 41 | prefix: "deps(api)" 42 | include: "scope" 43 | 44 | # GitHub Actions workflows 45 | - package-ecosystem: "github-actions" 46 | directory: "/" 47 | schedule: 48 | interval: "monthly" 49 | open-pull-requests-limit: 5 50 | commit-message: 51 | prefix: "ci" 52 | include: "scope" -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: "Release Version" 2 | 3 | permissions: 4 | id-token: write 5 | contents: write 6 | issues: write 7 | pull-requests: write 8 | 9 | on: 10 | push: 11 | branches: 12 | - master 13 | 14 | jobs: 15 | release-please: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: googleapis/release-please-action@v4 19 | with: 20 | config-file: release-please-config.json 21 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | name: Run Biome Linter and Formatter 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set up pnpm 18 | uses: pnpm/action-setup@v4 19 | with: 20 | run_install: true 21 | 22 | - name: Setup Biome CLI 23 | uses: biomejs/setup-biome@v2 24 | 25 | - name: Run Biome 26 | run: biome ci 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules/ 3 | /.pnp 4 | .pnp.js 5 | .yarn/install-state.gz 6 | 7 | # testing 8 | /coverage 9 | 10 | # next.js 11 | /.next/ 12 | /out/ 13 | 14 | # production 15 | /build 16 | 17 | # misc 18 | .DS_Store 19 | *.pem 20 | 21 | # debug 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # local env files 27 | .env*.local 28 | 29 | # vercel 30 | .vercel 31 | 32 | # typescript 33 | *.tsbuildinfo 34 | next-env.d.ts 35 | 36 | # OpenNext 37 | /.open-next 38 | 39 | # wrangler files 40 | .wrangler 41 | .wrangler_backup 42 | .env 43 | .env.production 44 | .dev.vars 45 | .prod.vars 46 | 47 | # intelij 48 | .idea -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "1.7.1" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "wrangler.json": "jsonc" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@solstatus/api", 3 | "version": "1.0.0", 4 | "private": true, 5 | "main": "src/index.ts", 6 | "types": "src/index.ts" 7 | } 8 | -------------------------------------------------------------------------------- /api/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./monitor-exec" 2 | export * from "./monitor-trigger" 3 | -------------------------------------------------------------------------------- /api/wrangler-monitor-exec.jsonc: -------------------------------------------------------------------------------- 1 | // https://developers.cloudflare.com/workers/wrangler/configuration/ 2 | { 3 | "$schema": "../node_modules/wrangler/config-schema.json", 4 | "name": "monitor-exec", 5 | "main": "src/monitor-exec.ts", 6 | // https://developers.cloudflare.com/workers/configuration/compatibility-flags/#flags-history 7 | "compatibility_date": "2025-04-01", 8 | "compatibility_flags": ["nodejs_compat"], 9 | "minify": true, 10 | "workers_dev": false, 11 | "observability": { 12 | "enabled": true 13 | }, 14 | "vars": { 15 | "APP_ENV": "development" 16 | }, 17 | "d1_databases": [ 18 | { 19 | "binding": "DB", 20 | "database_name": "solstatus-local", 21 | "database_id": "solstatus-local", 22 | "migrations_dir": "src/db/migrations" 23 | } 24 | ], 25 | "env": { 26 | "production": { 27 | "vars": { 28 | "APP_ENV": "production" 29 | }, 30 | "d1_databases": [ 31 | { 32 | "binding": "DB", 33 | "database_name": "solstatus-prod", 34 | "database_id": "UPDATE_ME_D1_ID", 35 | "migrations_dir": "src/db/migrations" 36 | } 37 | ] 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /api/wrangler-monitor-trigger.jsonc: -------------------------------------------------------------------------------- 1 | // https://developers.cloudflare.com/workers/wrangler/configuration/ 2 | { 3 | "$schema": "../node_modules/wrangler/config-schema.json", 4 | "name": "monitor-trigger", 5 | "main": "src/monitor-trigger.ts", 6 | // https://developers.cloudflare.com/workers/configuration/compatibility-flags/#flags-history 7 | "compatibility_date": "2025-04-01", 8 | "compatibility_flags": ["nodejs_compat"], 9 | "minify": true, 10 | "workers_dev": false, 11 | "observability": { 12 | "enabled": true 13 | }, 14 | "vars": { 15 | "ENVIRONMENT": "development" 16 | }, 17 | "d1_databases": [ 18 | { 19 | "binding": "DB", 20 | "database_name": "solstatus-local", 21 | "database_id": "solstatus-local", 22 | "migrations_dir": "src/db/migrations" 23 | } 24 | ], 25 | "services": [ 26 | { 27 | "binding": "MONITOR_EXEC", 28 | "service": "monitor-exec" 29 | } 30 | ], 31 | "durable_objects": { 32 | "bindings": [ 33 | { 34 | "name": "MONITOR_TRIGGER", 35 | "class_name": "MonitorTrigger" 36 | } 37 | ] 38 | }, 39 | "migrations": [ 40 | { 41 | "tag": "v1", 42 | "new_sqlite_classes": ["MonitorTrigger"] 43 | } 44 | ], 45 | "env": { 46 | "production": { 47 | "vars": { 48 | "ENVIRONMENT": "production" 49 | }, 50 | "d1_databases": [ 51 | { 52 | "binding": "DB", 53 | "database_name": "solstatus-prod", 54 | "database_id": "UPDATE_ME_D1_ID", 55 | "migrations_dir": "src/db/migrations" 56 | } 57 | ], 58 | "services": [ 59 | { 60 | "binding": "MONITOR_EXEC", 61 | "service": "monitor-exec-production" 62 | } 63 | ], 64 | "durable_objects": { 65 | "bindings": [ 66 | { 67 | "name": "MONITOR_TRIGGER", 68 | "class_name": "MonitorTrigger", 69 | "script_name": "monitor-trigger-production" 70 | } 71 | ] 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /biome.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.0.0-beta.5/schema.json", 3 | "css": { 4 | "linter": { 5 | "enabled": true 6 | } 7 | }, 8 | "javascript": { 9 | "formatter": { 10 | "semicolons": "asNeeded" 11 | } 12 | }, 13 | "vcs": { 14 | "enabled": true, 15 | "clientKind": "git", 16 | "useIgnoreFile": true 17 | }, 18 | "formatter": { 19 | "enabled": true, 20 | "indentStyle": "space" 21 | }, 22 | "assist": { 23 | "actions": { 24 | "source": { 25 | "organizeImports": "on" 26 | } 27 | } 28 | }, 29 | "linter": { 30 | "enabled": true, 31 | "rules": { 32 | "style": { 33 | "useBlockStatements": { 34 | "level": "error" 35 | }, 36 | "useLiteralEnumMembers": "error", 37 | "noCommaOperator": "error", 38 | "useNodejsImportProtocol": "error", 39 | "useAsConstAssertion": "error", 40 | "useEnumInitializers": "error", 41 | "useSelfClosingElements": "error", 42 | "useConst": "error", 43 | "useSingleVarDeclarator": "error", 44 | "noUnusedTemplateLiteral": "error", 45 | "useNumberNamespace": "error", 46 | "noInferrableTypes": "error", 47 | "useExponentiationOperator": "error", 48 | "useTemplate": "error", 49 | "noParameterAssign": "error", 50 | "noNonNullAssertion": "error", 51 | "useDefaultParameterLast": "error", 52 | "noArguments": "error", 53 | "useImportType": "error", 54 | "useExportType": "error", 55 | "noUselessElse": "error", 56 | "useShorthandFunctionType": "error" 57 | }, 58 | "correctness": { 59 | "noUnusedImports": { 60 | "level": "error" 61 | }, 62 | "noUnusedVariables": { 63 | "level": "error" 64 | } 65 | }, 66 | "complexity": { 67 | "useNumericLiterals": "error" 68 | }, 69 | "performance": { 70 | "noNamespaceImport": { 71 | "level": "error" 72 | } 73 | }, 74 | "a11y": { 75 | "useSemanticElements": { 76 | "level": "off" 77 | } 78 | } 79 | } 80 | }, 81 | "files": { 82 | "includes": [ 83 | "**", 84 | "!**/src/registry", 85 | "!**/cloudflare-env-gen.d.ts", 86 | "!**/meta/*.json", 87 | "!**/dist", 88 | "!**/tsconfig.json", 89 | "!**/node_modules/**" 90 | ] 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /cloudflare-env.d.ts: -------------------------------------------------------------------------------- 1 | // Custom changes to the generated types if they are not natively supported by `wrangler types` 2 | // https://developers.cloudflare.com/workers/wrangler/commands/#types 3 | interface CloudflareEnv extends CloudflareEnvGen { 4 | // Durable Objects types are not exported by `wrangler types` (yet) 5 | // https://github.com/cloudflare/workers-sdk/issues/6905 6 | MONITOR_TRIGGER: DurableObjectNamespace< 7 | import("@solstatus/api/src").MonitorTrigger 8 | > 9 | MONITOR_TRIGGER_RPC: Service 10 | MONITOR_EXEC: Service 11 | 12 | // Secrets 13 | OPSGENIE_API_KEY: string 14 | } 15 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/registry/new-york-v4/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /docs/dashboard-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unibeck/solstatus/143d3a89a2c44525dde636268c6cd90fcf183d78/docs/dashboard-demo.gif -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs" 2 | import path from "node:path" 3 | import { defineConfig } from "drizzle-kit" 4 | 5 | let dbConfig: ReturnType 6 | 7 | if (process.env.APP_ENV === "production") { 8 | dbConfig = defineConfig({ 9 | schema: "./src/db/schema", 10 | out: "./src/db/migrations", 11 | dialect: "sqlite", 12 | driver: "d1-http", 13 | dbCredentials: { 14 | accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "", 15 | token: process.env.CLOUDFLARE_API_TOKEN ?? "", 16 | databaseId: "UPDATE_ME_D1_ID", 17 | }, 18 | }) 19 | } else { 20 | const localD1DB = getLocalD1DB() 21 | if (!localD1DB) { 22 | process.exit(1) 23 | } 24 | 25 | dbConfig = defineConfig({ 26 | schema: "./src/db/schema", 27 | out: "./src/db/migrations", 28 | dialect: "sqlite", 29 | dbCredentials: { 30 | url: localD1DB, 31 | }, 32 | }) 33 | } 34 | 35 | export default dbConfig 36 | 37 | function getLocalD1DB() { 38 | try { 39 | const basePath = path.resolve(".wrangler") 40 | const files = fs 41 | .readdirSync(basePath, { encoding: "utf-8", recursive: true }) 42 | .filter((f) => f.endsWith(".sqlite")) 43 | 44 | // In case there are multiple .sqlite files, we want the most recent one. 45 | files.sort((a, b) => { 46 | const statA = fs.statSync(path.join(basePath, a)) 47 | const statB = fs.statSync(path.join(basePath, b)) 48 | return statB.mtime.getTime() - statA.mtime.getTime() 49 | }) 50 | const dbFile = files[0] 51 | 52 | if (!dbFile) { 53 | throw new Error(`.sqlite file not found in ${basePath}`) 54 | } 55 | 56 | const url = path.resolve(basePath, dbFile) 57 | 58 | return url 59 | } catch (err) { 60 | if (err instanceof Error) { 61 | console.log(`Error resolving local D1 DB: ${err.message}`) 62 | } else { 63 | console.log(`Error resolving local D1 DB: ${err}`) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "node:child_process" 2 | import type { NextConfig } from "next" 3 | 4 | // x-release-please-start-version 5 | const APP_VERSION = "1.7.1" 6 | // x-release-please-end-version 7 | 8 | let gitCommitSHA = "dev" 9 | console.log(`APP_ENV: ${process.env.APP_ENV}`) 10 | if (process.env.APP_ENV !== "development") { 11 | try { 12 | gitCommitSHA = execSync("git rev-parse --short HEAD") 13 | .toString() 14 | .trim() 15 | .substring(0, 7) 16 | } catch (error) { 17 | console.error("Error getting git commit SHA:", error) 18 | } 19 | } 20 | const fqAppVersion = `v${APP_VERSION}-${gitCommitSHA}` 21 | console.log(`NEXT_PUBLIC_APP_VERSION: ${fqAppVersion}`) 22 | 23 | const nextConfig: NextConfig = { 24 | env: { 25 | NEXT_PUBLIC_APP_VERSION: fqAppVersion, 26 | NEXT_PUBLIC_APP_ENV: process.env.APP_ENV, 27 | }, 28 | outputFileTracingIncludes: { 29 | "/*": ["./registry/**/*"], 30 | }, 31 | experimental: { 32 | webpackBuildWorker: true, 33 | parallelServerBuildTraces: true, 34 | parallelServerCompiles: true, 35 | serverSourceMaps: true, 36 | typedRoutes: true, 37 | reactCompiler: true, 38 | }, 39 | } 40 | 41 | const withBundleAnalyzer = require("@next/bundle-analyzer")({ 42 | enabled: process.env.ANALYZE === "true", 43 | }) 44 | 45 | export default withBundleAnalyzer(nextConfig) 46 | 47 | // added by create cloudflare to enable calling `getCloudflareContext()` in `next dev` 48 | import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare" 49 | 50 | initOpenNextCloudflareForDev() 51 | -------------------------------------------------------------------------------- /open-next.config.ts: -------------------------------------------------------------------------------- 1 | import { defineCloudflareConfig } from "@opennextjs/cloudflare" 2 | import kvIncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/kv-incremental-cache" 3 | 4 | export default defineCloudflareConfig({ 5 | incrementalCache: kvIncrementalCache, 6 | }) 7 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - '.' 3 | - 'api' 4 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | } 6 | export default config 7 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unibeck/solstatus/143d3a89a2c44525dde636268c6cd90fcf183d78/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unibeck/solstatus/143d3a89a2c44525dde636268c6cd90fcf183d78/public/icon-192.png -------------------------------------------------------------------------------- /public/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unibeck/solstatus/143d3a89a2c44525dde636268c6cd90fcf183d78/public/icon-512.png -------------------------------------------------------------------------------- /public/og_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unibeck/solstatus/143d3a89a2c44525dde636268c6cd90fcf183d78/public/og_image.png -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "include-v-in-tag": true, 3 | "tag-separator": "@", 4 | "draft": false, 5 | "prerelease": false, 6 | "packages": { 7 | ".": { 8 | "release-type": "node" 9 | } 10 | }, 11 | "extra-files": ["next.config.ts"], 12 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" 13 | } 14 | -------------------------------------------------------------------------------- /scripts/bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cp .dev.vars.example .dev.vars 4 | cp .env.example .env 5 | 6 | # Install dependencies 7 | pnpm i 8 | 9 | # Run migrations 10 | yes | pnpm db:setup 11 | -------------------------------------------------------------------------------- /scripts/log-opennext-bundle.js: -------------------------------------------------------------------------------- 1 | // Script to log the OpenNext bundle size 2 | import { existsSync, readFileSync } from "node:fs" 3 | import { join } from "node:path" 4 | import { gzipSync } from "node:zlib" 5 | 6 | async function logBundleSize() { 7 | console.log("Logging OpenNext bundle size...") 8 | const handlerPath = join( 9 | process.cwd(), 10 | ".open-next/server-functions/default/handler.mjs", 11 | ) 12 | 13 | if (!existsSync(handlerPath)) { 14 | console.error("Handler file not found:", handlerPath) 15 | return 16 | } 17 | const content = readFileSync(handlerPath, "utf8") 18 | 19 | const originalSize = Buffer.byteLength(content, "utf8") 20 | console.log(`Original size: ${formatBytes(originalSize)}`) 21 | 22 | const compressedSize = gzipSync(content).length 23 | console.log(`Compressed size: ${formatBytes(compressedSize)}`) 24 | } 25 | 26 | function formatBytes(bytes, decimals = 2) { 27 | if (bytes === 0) { 28 | return "0 Bytes" 29 | } 30 | 31 | const k = 1024 32 | const sizes = ["Bytes", "KB", "MB", "GB"] 33 | const i = Math.floor(Math.log(bytes) / Math.log(k)) 34 | 35 | return `${Number.parseFloat((bytes / k ** i).toFixed(decimals))} ${sizes[i]}` 36 | } 37 | 38 | logBundleSize().catch(console.error) 39 | -------------------------------------------------------------------------------- /src/app/api/endpoint-monitors/[id]/checks/history/route.ts: -------------------------------------------------------------------------------- 1 | import { getCloudflareContext } from "@opennextjs/cloudflare" 2 | import { and, desc, eq, sql } from "drizzle-orm" 3 | import { NextResponse } from "next/server" 4 | import { INTERNAL_SERVER_ERROR, OK } from "stoker/http-status-codes" 5 | import { useDrizzle } from "@/db" 6 | import { UptimeChecksTable } from "@/db/schema" 7 | import { createRoute } from "@/lib/api-utils" 8 | import { daysQuerySchema, idStringParamsSchema } from "@/lib/route-schemas" 9 | 10 | /** 11 | * GET /api/endpoint-monitors/[id]/checks/history 12 | * 13 | * Retrieves the history of uptime checks for a specific endpointMonitor within a given time period. 14 | * 15 | * @params {string} id - Endpoint Monitor ID 16 | * @query {number} days - Number of days to look back 17 | * @returns {Promise} JSON response with uptime check history 18 | * @throws {NextResponse} 500 Internal Server Error on database errors 19 | */ 20 | export const GET = createRoute 21 | .params(idStringParamsSchema) 22 | .query(daysQuerySchema()) 23 | .handler(async (_request, context) => { 24 | const { env } = getCloudflareContext() 25 | const db = useDrizzle(env.DB) 26 | 27 | const { days } = context.query 28 | 29 | try { 30 | const startDate = new Date() 31 | startDate.setDate(startDate.getDate() - days) 32 | 33 | const results = await db 34 | .select() 35 | .from(UptimeChecksTable) 36 | .where( 37 | and( 38 | eq(UptimeChecksTable.endpointMonitorId, context.params.id), 39 | sql`${UptimeChecksTable.timestamp} >= ${startDate.toISOString()}`, 40 | ), 41 | ) 42 | .orderBy(desc(UptimeChecksTable.timestamp)) 43 | 44 | return NextResponse.json(results, { status: OK }) 45 | } catch (error) { 46 | console.error("Error fetching uptime checks history: ", error) 47 | return NextResponse.json( 48 | { error: "Failed to fetch uptime checks history" }, 49 | { status: INTERNAL_SERVER_ERROR }, 50 | ) 51 | } 52 | }) 53 | -------------------------------------------------------------------------------- /src/app/api/endpoint-monitors/[id]/checks/route.ts: -------------------------------------------------------------------------------- 1 | import { getCloudflareContext } from "@opennextjs/cloudflare" 2 | import { and, desc, eq, gt } from "drizzle-orm" 3 | import { NextResponse } from "next/server" 4 | import { INTERNAL_SERVER_ERROR, OK } from "stoker/http-status-codes" 5 | import { useDrizzle } from "@/db" 6 | import { UptimeChecksTable } from "@/db/schema" 7 | import { createRoute } from "@/lib/api-utils" 8 | import { idStringParamsSchema, timeRangeQuerySchema } from "@/lib/route-schemas" 9 | import { getTimeRangeInMinutes } from "@/lib/uptime-utils" 10 | import type { TimeRange } from "@/types/endpointMonitor" 11 | 12 | /** 13 | * GET /api/endpoint-monitors/[id]/checks 14 | * 15 | * Retrieves uptime checks for a specific endpointMonitor within a given time range. 16 | * 17 | * @params {string} id - EndpointMonitor ID 18 | * @query {TimeRange} timeRange - Time range to filter results 19 | * @returns {Promise} JSON response with uptime checks 20 | * @throws {NextResponse} 500 Internal Server Error on database errors 21 | */ 22 | export const GET = createRoute 23 | .params(idStringParamsSchema) 24 | .query(timeRangeQuerySchema) 25 | .handler(async (_request, context) => { 26 | const { env } = getCloudflareContext() 27 | const db = useDrizzle(env.DB) 28 | const { timeRange } = context.query 29 | 30 | try { 31 | // Calculate start time based on time range 32 | const startTime = new Date() 33 | startTime.setMinutes( 34 | startTime.getMinutes() - getTimeRangeInMinutes(timeRange as TimeRange), 35 | ) 36 | 37 | const results = await db 38 | .select() 39 | .from(UptimeChecksTable) 40 | .where( 41 | and( 42 | eq(UptimeChecksTable.endpointMonitorId, context.params.id), 43 | gt(UptimeChecksTable.timestamp, startTime), 44 | ), 45 | ) 46 | .orderBy(desc(UptimeChecksTable.timestamp)) 47 | 48 | return NextResponse.json(results, { status: OK }) 49 | } catch (error) { 50 | console.error("Error fetching uptime checks: ", error) 51 | return NextResponse.json( 52 | { error: "Failed to fetch uptime checks" }, 53 | { status: INTERNAL_SERVER_ERROR }, 54 | ) 55 | } 56 | }) 57 | -------------------------------------------------------------------------------- /src/app/api/endpoint-monitors/[id]/execute-check/route.ts: -------------------------------------------------------------------------------- 1 | import { getCloudflareContext } from "@opennextjs/cloudflare" 2 | import { eq } from "drizzle-orm" 3 | import { NextResponse } from "next/server" 4 | import { OK } from "stoker/http-status-codes" 5 | import { takeUniqueOrThrow, useDrizzle } from "@/db" 6 | import { EndpointMonitorsTable } from "@/db/schema" 7 | import { createRoute } from "@/lib/api-utils" 8 | import { idStringParamsSchema } from "@/lib/route-schemas" 9 | 10 | /** 11 | * GET /api/endpoint-monitors/[id]/execute-check 12 | * 13 | * Manually executes an uptime check for a specific endpointMonitor. 14 | * 15 | * @params {string} id - Endpoint Monitor ID 16 | * @returns {Promise} JSON response confirming the check execution 17 | */ 18 | export const GET = createRoute 19 | .params(idStringParamsSchema) 20 | .handler(async (_request, context) => { 21 | const { env } = getCloudflareContext() 22 | const db = useDrizzle(env.DB) 23 | 24 | const endpointMonitor = await db 25 | .select() 26 | .from(EndpointMonitorsTable) 27 | .where(eq(EndpointMonitorsTable.id, context.params.id)) 28 | .then(takeUniqueOrThrow) 29 | 30 | await env.MONITOR_EXEC.executeCheck(endpointMonitor.id) 31 | 32 | return NextResponse.json( 33 | { message: "Executed check via DO" }, 34 | { status: OK }, 35 | ) 36 | }) 37 | -------------------------------------------------------------------------------- /src/app/api/endpoint-monitors/[id]/init-do/route.ts: -------------------------------------------------------------------------------- 1 | import { getCloudflareContext } from "@opennextjs/cloudflare" 2 | import type { InitPayload } from "@solstatus/api/src" 3 | import { eq } from "drizzle-orm" 4 | import { NextResponse } from "next/server" 5 | import { OK } from "stoker/http-status-codes" 6 | import { takeUniqueOrThrow, useDrizzle } from "@/db" 7 | import { EndpointMonitorsTable } from "@/db/schema" 8 | import { createRoute } from "@/lib/api-utils" 9 | import { idStringParamsSchema } from "@/lib/route-schemas" 10 | 11 | /** 12 | * POST /api/endpoint-monitors/[id]/init-do 13 | * 14 | * Initializes a new Monitor DO for a specific endpointMonitor. 15 | * 16 | * @params {string} id - Endpoint Monitor ID 17 | * @returns {Promise} JSON response confirming the Monitor DO has been initialized 18 | */ 19 | export const POST = createRoute 20 | .params(idStringParamsSchema) 21 | .handler(async (_request, context) => { 22 | const { env } = getCloudflareContext() 23 | const db = useDrizzle(env.DB) 24 | const endpointMonitor = await db 25 | .select() 26 | .from(EndpointMonitorsTable) 27 | .where(eq(EndpointMonitorsTable.id, context.params.id)) 28 | .then(takeUniqueOrThrow) 29 | 30 | await env.MONITOR_TRIGGER_RPC.init({ 31 | monitorId: endpointMonitor.id, 32 | monitorType: "endpoint", 33 | checkInterval: endpointMonitor.checkInterval, 34 | } as InitPayload) 35 | 36 | return NextResponse.json( 37 | { message: "Initialized Monitor DO" }, 38 | { status: OK }, 39 | ) 40 | }) 41 | -------------------------------------------------------------------------------- /src/app/api/endpoint-monitors/[id]/pause/route.ts: -------------------------------------------------------------------------------- 1 | import { getCloudflareContext } from "@opennextjs/cloudflare" 2 | import { NextResponse } from "next/server" 3 | import { OK } from "stoker/http-status-codes" 4 | import { createRoute } from "@/lib/api-utils" 5 | import { idStringParamsSchema } from "@/lib/route-schemas" 6 | 7 | /** 8 | * POST /api/endpoint-monitors/[id]/pause 9 | * 10 | * Pauses monitoring for a specific endpointMonitor. 11 | * 12 | * @params {string} id - Endpoint Monitor ID 13 | * @returns {Promise} JSON response confirming the monitoring has been paused 14 | */ 15 | export const POST = createRoute 16 | .params(idStringParamsSchema) 17 | .handler(async (_request, context) => { 18 | const { env } = getCloudflareContext() 19 | await env.MONITOR_TRIGGER_RPC.pauseDo(context.params.id) 20 | 21 | return NextResponse.json({ message: "Paused Monitor DO" }, { status: OK }) 22 | }) 23 | -------------------------------------------------------------------------------- /src/app/api/endpoint-monitors/[id]/resume/route.ts: -------------------------------------------------------------------------------- 1 | import { getCloudflareContext } from "@opennextjs/cloudflare" 2 | import type { InitPayload } from "@solstatus/api/src/monitor-trigger" 3 | import { eq } from "drizzle-orm" 4 | import { NextResponse } from "next/server" 5 | import { OK } from "stoker/http-status-codes" 6 | import { takeUniqueOrThrow, useDrizzle } from "@/db" 7 | import { EndpointMonitorsTable } from "@/db/schema" 8 | import { createRoute } from "@/lib/api-utils" 9 | import { 10 | getErrorMessage, 11 | MonitorTriggerNotInitializedError, 12 | } from "@/lib/errors" 13 | import { idStringParamsSchema } from "@/lib/route-schemas" 14 | 15 | /** 16 | * POST /api/endpoint-monitors/[id]/resume 17 | * 18 | * Resumes monitoring for a specific endpointMonitor. 19 | * 20 | * @params {string} id - Endpoint Monitor ID 21 | * @returns {Promise} JSON response confirming the monitoring has been resumed 22 | */ 23 | export const POST = createRoute 24 | .params(idStringParamsSchema) 25 | .handler(async (_request, context) => { 26 | const { env } = getCloudflareContext() 27 | const db = useDrizzle(env.DB) 28 | const endpointMonitor = await db 29 | .select() 30 | .from(EndpointMonitorsTable) 31 | .where(eq(EndpointMonitorsTable.id, context.params.id)) 32 | .then(takeUniqueOrThrow) 33 | 34 | try { 35 | await env.MONITOR_TRIGGER_RPC.updateCheckInterval( 36 | endpointMonitor.id, 37 | endpointMonitor.checkInterval, 38 | ) 39 | await env.MONITOR_TRIGGER_RPC.resumeDo(endpointMonitor.id) 40 | } catch (error) { 41 | const errorMessage = getErrorMessage(error) 42 | 43 | // RPC returns a wrapped, stringified error, so we need to check for the error name 44 | if (errorMessage.includes(MonitorTriggerNotInitializedError.NAME)) { 45 | console.log( 46 | `DO [${endpointMonitor.id}] not initialized. Initializing automatically...`, 47 | ) 48 | await env.MONITOR_TRIGGER_RPC.init({ 49 | monitorId: endpointMonitor.id, 50 | monitorType: "endpoint", 51 | checkInterval: endpointMonitor.checkInterval, 52 | } as InitPayload) 53 | } else { 54 | throw error 55 | } 56 | } 57 | 58 | return NextResponse.json({ message: "Resumed Monitor DO" }, { status: OK }) 59 | }) 60 | -------------------------------------------------------------------------------- /src/app/api/endpoint-monitors/[id]/status/route.ts: -------------------------------------------------------------------------------- 1 | import { getCloudflareContext } from "@opennextjs/cloudflare" 2 | import { eq } from "drizzle-orm" 3 | import { NextResponse } from "next/server" 4 | import { OK } from "stoker/http-status-codes" 5 | import { takeUniqueOrThrow, useDrizzle } from "@/db" 6 | import { EndpointMonitorsTable } from "@/db/schema" 7 | import { createRoute } from "@/lib/api-utils" 8 | import { idStringParamsSchema } from "@/lib/route-schemas" 9 | 10 | /** 11 | * GET /api/endpoint-monitors/[id]/status 12 | * 13 | * Retrieves the current monitoring status of a specific endpointMonitor. 14 | * 15 | * @params {string} id - Endpoint Monitor ID 16 | * @returns {Promise} JSON response with the endpointMonitor's running status 17 | */ 18 | export const GET = createRoute 19 | .params(idStringParamsSchema) 20 | .handler(async (_request, context) => { 21 | const { env } = getCloudflareContext() 22 | const db = useDrizzle(env.DB) 23 | const endpointMonitor = await db 24 | .select() 25 | .from(EndpointMonitorsTable) 26 | .where(eq(EndpointMonitorsTable.id, context.params.id)) 27 | .then(takeUniqueOrThrow) 28 | 29 | return NextResponse.json( 30 | { status: endpointMonitor.isRunning }, 31 | { status: OK }, 32 | ) 33 | }) 34 | -------------------------------------------------------------------------------- /src/app/api/endpoint-monitors/[id]/uptime/limit/route.ts: -------------------------------------------------------------------------------- 1 | import { getCloudflareContext } from "@opennextjs/cloudflare" 2 | import { and, desc, eq, isNotNull } from "drizzle-orm" 3 | import { NextResponse } from "next/server" 4 | import { INTERNAL_SERVER_ERROR, OK } from "stoker/http-status-codes" 5 | import { z } from "zod" 6 | import { useDrizzle } from "@/db" 7 | import { UptimeChecksTable } from "@/db/schema" 8 | import type { uptimeChecksSelectSchema } from "@/db/zod-schema" 9 | import { createRoute } from "@/lib/api-utils" 10 | import { idStringParamsSchema } from "@/lib/route-schemas" 11 | 12 | const querySchema = z.object({ 13 | limit: z.coerce.number().optional().default(30), 14 | }) 15 | 16 | /** 17 | * GET /api/endpoint-monitors/[id]/uptime/limit 18 | * 19 | * Retrieves uptime limit data for a specific endpointMonitor. 20 | * 21 | * @params {string} id - EndpointMonitor ID 22 | * @query {number} limit - Maximum number of data points to return (default: 30) 23 | * @returns {Promise} JSON response with uptime limit data in chronological order 24 | * @throws {NextResponse} 500 Internal Server Error on database errors 25 | */ 26 | export const GET = createRoute 27 | .params(idStringParamsSchema) 28 | .query(querySchema) 29 | .handler(async (_request, context) => { 30 | const { env } = getCloudflareContext() 31 | const db = useDrizzle(env.DB) 32 | const { id: endpointMonitorId } = context.params 33 | const { limit } = context.query 34 | 35 | try { 36 | const results: z.infer[] = await db 37 | .select() 38 | .from(UptimeChecksTable) 39 | .where( 40 | and( 41 | eq(UptimeChecksTable.endpointMonitorId, endpointMonitorId), 42 | isNotNull(UptimeChecksTable.responseTime), 43 | ), 44 | ) 45 | // order by timestamp descending to get the most recent first 46 | .orderBy(desc(UptimeChecksTable.timestamp)) 47 | .limit(limit) 48 | // reverse the results put it back in chronological order 49 | .then((results) => results.reverse()) 50 | 51 | return NextResponse.json(results, { status: OK }) 52 | } catch (error) { 53 | console.error("Error fetching latency data: ", error) 54 | return NextResponse.json( 55 | { error: "Failed to fetch latency data" }, 56 | { status: INTERNAL_SERVER_ERROR }, 57 | ) 58 | } 59 | }) 60 | -------------------------------------------------------------------------------- /src/app/api/endpoint-monitors/[id]/uptime/range/route.ts: -------------------------------------------------------------------------------- 1 | import { getCloudflareContext } from "@opennextjs/cloudflare" 2 | import { subDays, subHours, subWeeks } from "date-fns" 3 | import { and, eq, gt } from "drizzle-orm" 4 | import { NextResponse } from "next/server" 5 | import { INTERNAL_SERVER_ERROR, OK } from "stoker/http-status-codes" 6 | import { z } from "zod" 7 | import { useDrizzle } from "@/db" 8 | import { UptimeChecksTable } from "@/db/schema" 9 | import type { uptimeChecksSelectSchema } from "@/db/zod-schema" 10 | import { createRoute } from "@/lib/api-utils" 11 | import { idStringParamsSchema } from "@/lib/route-schemas" 12 | 13 | const querySchema = z.object({ 14 | range: z.enum(["1h", "1d", "7d"]).default("1h"), 15 | }) 16 | 17 | /** 18 | * GET /api/endpoint-monitors/[id]/uptime/range 19 | * 20 | * Retrieves uptime data for a specific endpointMonitor within a given time range. 21 | * 22 | * @params {string} id - EndpointMonitor ID 23 | * @query {string} range - Time range ('1h', '1d', '7d', default: '1h') 24 | * @returns {Promise} JSON response with uptime data 25 | * @throws {NextResponse} 500 Internal Server Error on database errors 26 | */ 27 | export const GET = createRoute 28 | .params(idStringParamsSchema) 29 | .query(querySchema) 30 | .handler(async (_request, context) => { 31 | const { env } = getCloudflareContext() 32 | const db = useDrizzle(env.DB) 33 | const { id: endpointMonitorId } = context.params 34 | const { range } = context.query 35 | 36 | const now = new Date() 37 | let startTime: Date 38 | switch (range) { 39 | case "1d": 40 | startTime = subDays(now, 1) 41 | break 42 | case "7d": 43 | startTime = subWeeks(now, 1) 44 | break 45 | default: 46 | startTime = subHours(now, 1) 47 | break 48 | } 49 | 50 | try { 51 | const results: z.infer[] = await db 52 | .select() 53 | .from(UptimeChecksTable) 54 | .where( 55 | and( 56 | eq(UptimeChecksTable.endpointMonitorId, endpointMonitorId), 57 | gt(UptimeChecksTable.timestamp, startTime), 58 | ), 59 | ) 60 | .orderBy(UptimeChecksTable.timestamp) 61 | 62 | console.log( 63 | `Uptime checks in range [${range}] for endpointMonitor [${endpointMonitorId}]: ${results.length}`, 64 | ) 65 | return NextResponse.json(results, { status: OK }) 66 | } catch (error) { 67 | console.error("Error fetching uptime data: ", error) 68 | return NextResponse.json( 69 | { error: "Failed to fetch uptime data" }, 70 | { status: INTERNAL_SERVER_ERROR }, 71 | ) 72 | } 73 | }) 74 | -------------------------------------------------------------------------------- /src/app/api/endpoint-monitors/[id]/uptime/route.ts: -------------------------------------------------------------------------------- 1 | import { getCloudflareContext } from "@opennextjs/cloudflare" 2 | import { desc, eq } from "drizzle-orm" 3 | import { NextResponse } from "next/server" 4 | import { INTERNAL_SERVER_ERROR, OK } from "stoker/http-status-codes" 5 | import type { z } from "zod" 6 | import { takeUniqueOrThrow, useDrizzle } from "@/db" 7 | import { UptimeChecksTable } from "@/db/schema" 8 | import type { uptimeChecksSelectSchema } from "@/db/zod-schema" 9 | import { createRoute } from "@/lib/api-utils" 10 | import { idStringParamsSchema } from "@/lib/route-schemas" 11 | 12 | /** 13 | * GET /api/endpoint-monitors/[id]/uptime 14 | * 15 | * Get the latest uptime check for a endpointMonitor 16 | * 17 | * @params {string} id - EndpointMonitor ID 18 | * @returns {Promise} JSON response with uptime percentage and period 19 | * @throws {NextResponse} 404 Not Found if no uptime checks found for the endpointMonitor 20 | * @throws {NextResponse} 500 Internal Server Error on database errors 21 | */ 22 | export const GET = createRoute 23 | .params(idStringParamsSchema) 24 | .handler(async (_request, context) => { 25 | const { env } = getCloudflareContext() 26 | const db = useDrizzle(env.DB) 27 | const { id: endpointMonitorId } = context.params 28 | 29 | try { 30 | const result: z.infer = await db 31 | .select() 32 | .from(UptimeChecksTable) 33 | .where(eq(UptimeChecksTable.endpointMonitorId, endpointMonitorId)) 34 | .orderBy(desc(UptimeChecksTable.timestamp)) 35 | .limit(1) 36 | .then(takeUniqueOrThrow) 37 | 38 | return NextResponse.json(result, { status: OK }) 39 | } catch (error) { 40 | console.error( 41 | `Error getting latest uptime check for endpointMonitor [${endpointMonitorId}]: ${error}`, 42 | ) 43 | return NextResponse.json( 44 | { error: "Failed to get latest uptime check" }, 45 | { status: INTERNAL_SERVER_ERROR }, 46 | ) 47 | } 48 | }) 49 | -------------------------------------------------------------------------------- /src/app/api/endpoint-monitors/count/route.ts: -------------------------------------------------------------------------------- 1 | import { getCloudflareContext } from "@opennextjs/cloudflare" 2 | import { and, count, eq, like, sql } from "drizzle-orm" 3 | import { NextResponse } from "next/server" 4 | import { z } from "zod" 5 | import { takeUniqueOrThrow, useDrizzle } from "@/db" 6 | import { EndpointMonitorsTable } from "@/db/schema" 7 | import { createRoute } from "@/lib/api-utils" 8 | 9 | /** 10 | * GET /api/endpoint-monitors/count 11 | * 12 | * Retrieves the total count of endpointMonitors in the database, subject to optional search and filter parameters. 13 | * 14 | * @query {string} search - Optional search term to filter endpointMonitors 15 | * @query {string} isRunning - Optional filter by running status 16 | * @query {number} checkIntervalMin - Optional filter by minimum check interval 17 | * @query {number} checkIntervalMax - Optional filter by maximum check interval 18 | * @returns {Promise} JSON response with the total count as a number 19 | */ 20 | const querySchema = z.object({ 21 | search: z.string().optional(), 22 | isRunning: z.string().optional(), 23 | checkIntervalMin: z.number().optional(), 24 | checkIntervalMax: z.number().optional(), 25 | }) 26 | 27 | export const GET = createRoute 28 | .query(querySchema) 29 | .handler(async (_request, context) => { 30 | const { env } = getCloudflareContext() 31 | const db = useDrizzle(env.DB) 32 | 33 | const { search, isRunning, checkIntervalMin, checkIntervalMax } = 34 | context.query 35 | 36 | const { count: totalCount } = await db 37 | .select({ count: count() }) 38 | .from(EndpointMonitorsTable) 39 | .where( 40 | and( 41 | search 42 | ? sql`(${like(EndpointMonitorsTable.name, `%${search}%`)} OR ${like(EndpointMonitorsTable.url, `%${search}%`)})` 43 | : sql`1=1`, 44 | isRunning !== undefined 45 | ? eq(EndpointMonitorsTable.isRunning, isRunning === "true") 46 | : sql`1=1`, 47 | checkIntervalMin !== undefined 48 | ? sql`${EndpointMonitorsTable.checkInterval} >= ${checkIntervalMin}` 49 | : sql`1=1`, 50 | checkIntervalMax !== undefined 51 | ? sql`${EndpointMonitorsTable.checkInterval} <= ${checkIntervalMax}` 52 | : sql`1=1`, 53 | ), 54 | ) 55 | .then(takeUniqueOrThrow) 56 | 57 | return NextResponse.json(totalCount) 58 | }) 59 | -------------------------------------------------------------------------------- /src/app/api/endpoint-monitors/stats/route.ts: -------------------------------------------------------------------------------- 1 | import { getCloudflareContext } from "@opennextjs/cloudflare" 2 | import { and, count, desc, eq, gt, isNotNull } from "drizzle-orm" 3 | import { NextResponse } from "next/server" 4 | import { INTERNAL_SERVER_ERROR, OK } from "stoker/http-status-codes" 5 | import { takeFirstOrNull, takeUniqueOrThrow, useDrizzle } from "@/db" 6 | import { EndpointMonitorsTable, UptimeChecksTable } from "@/db/schema" 7 | import { createRoute } from "@/lib/api-utils" 8 | 9 | // TODO: re-enable this, but since we use createZodRoute this endpoint can't be rendered statically 10 | // Cache duration in seconds 11 | // export const revalidate = 120 12 | 13 | /** 14 | * GET /api/endpoint-monitors/stats 15 | * 16 | * Retrieves aggregate statistics for endpointMonitor monitoring dashboard. 17 | * 18 | * @returns {Promise} JSON response with aggregate statistics 19 | * @throws {NextResponse} 500 Internal Server Error on database errors 20 | */ 21 | export const GET = createRoute.handler(async (_request, _context) => { 22 | const { env } = getCloudflareContext() 23 | const db = useDrizzle(env.DB) 24 | 25 | try { 26 | // Get total endpointMonitor count 27 | const { totalEndpointMonitors } = await db 28 | .select({ 29 | totalEndpointMonitors: count(), 30 | }) 31 | .from(EndpointMonitorsTable) 32 | .then(takeUniqueOrThrow) 33 | 34 | // Get count of endpointMonitors with active alerts 35 | const { sitesWithAlerts } = await db 36 | .select({ 37 | sitesWithAlerts: count(), 38 | }) 39 | .from(EndpointMonitorsTable) 40 | .where(eq(EndpointMonitorsTable.activeAlert, true)) 41 | .then(takeUniqueOrThrow) 42 | 43 | // Get highest response time and associated endpointMonitor ID in the last 24 hours 44 | const oneDayAgo = new Date() 45 | oneDayAgo.setDate(oneDayAgo.getDate() - 1) 46 | 47 | const highestCheck = await db 48 | .select({ 49 | highestResponseTime: UptimeChecksTable.responseTime, 50 | highestResponseTimeEndpointMonitorId: 51 | UptimeChecksTable.endpointMonitorId, 52 | }) 53 | .from(UptimeChecksTable) 54 | .where( 55 | and( 56 | gt(UptimeChecksTable.timestamp, oneDayAgo), 57 | isNotNull(UptimeChecksTable.responseTime), 58 | ), 59 | ) 60 | .orderBy(desc(UptimeChecksTable.responseTime)) 61 | .limit(1) 62 | .then(takeFirstOrNull) 63 | 64 | // Get overall uptime percentage in the last 24 hours 65 | const checksResult = await db 66 | .select({ 67 | isExpectedStatus: UptimeChecksTable.isExpectedStatus, 68 | }) 69 | .from(UptimeChecksTable) 70 | .where(gt(UptimeChecksTable.timestamp, oneDayAgo)) 71 | 72 | const totalChecks = checksResult.length 73 | const successfulChecks = checksResult.filter( 74 | (check) => check.isExpectedStatus, 75 | ).length 76 | 77 | const uptimePercentage = 78 | totalChecks > 0 ? (successfulChecks / totalChecks) * 100 : 100 79 | 80 | return NextResponse.json( 81 | { 82 | totalEndpointMonitors, 83 | sitesWithAlerts, 84 | highestResponseTime: highestCheck?.highestResponseTime ?? 0, 85 | highestResponseTimeEndpointMonitorId: 86 | highestCheck?.highestResponseTimeEndpointMonitorId ?? null, 87 | uptimePercentage: Math.round(uptimePercentage * 100) / 100, 88 | }, 89 | { 90 | status: OK, 91 | }, 92 | ) 93 | } catch (error) { 94 | console.error("Error fetching dashboard statistics: ", error) 95 | return NextResponse.json( 96 | { error: "Failed to fetch dashboard statistics" }, 97 | { status: INTERNAL_SERVER_ERROR }, 98 | ) 99 | } 100 | }) 101 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unibeck/solstatus/143d3a89a2c44525dde636268c6cd90fcf183d78/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { IconCirclePlusFilled } from "@tabler/icons-react" 4 | import { useEffect } from "react" 5 | import { AddEndpointMonitorDialog } from "@/components/add-endpoint-monitor-dialog" 6 | import { DataTable } from "@/components/data-table" 7 | import { SectionCards } from "@/components/section-cards" 8 | import { useHeaderContext } from "@/context/header-context" 9 | import { Button } from "@/registry/new-york-v4/ui/button" 10 | import { useStatsStore } from "@/store/dashboard-stats-store" 11 | import { useDataTableStore } from "@/store/data-table-store" 12 | 13 | export default function Page() { 14 | const { setHeaderLeftContent, setHeaderRightContent } = useHeaderContext() 15 | 16 | const fetchEndpointMonitors = useDataTableStore( 17 | (state) => state.fetchEndpointMonitors, 18 | ) 19 | const fetchDashboardStats = useStatsStore( 20 | (state) => state.fetchDashboardStats, 21 | ) 22 | 23 | useEffect(() => { 24 | setHeaderLeftContent("Endpoint Monitors") 25 | setHeaderRightContent( 26 | { 28 | await fetchEndpointMonitors() 29 | await fetchDashboardStats() 30 | }} 31 | trigger={ 32 | 36 | } 37 | />, 38 | ) 39 | }, [ 40 | setHeaderLeftContent, 41 | setHeaderRightContent, 42 | fetchEndpointMonitors, 43 | fetchDashboardStats, 44 | ]) 45 | 46 | return ( 47 |
48 |
49 | 50 | 51 |
52 |
53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/app/site.ts: -------------------------------------------------------------------------------- 1 | import { PROD_FQDN } from "@/lib/constants" 2 | 3 | export const siteConfig = { 4 | name: "SolStatus", 5 | url: `https://${PROD_FQDN}`, 6 | ogImage: `https://${PROD_FQDN}/og.jpg`, 7 | description: 8 | "An uptime monitoring service that is easy and cheap to run at scale. Create endpoint checks for uptime, latency, and status code. Supports OpsGenie, for alerts when there are two or more consecutive failures.", 9 | links: { 10 | twitter: "https://x.com/SolBeckman_", 11 | github: "https://github.com/unibeck/solstatus", 12 | }, 13 | defaultTheme: "mono-scaled", 14 | } 15 | 16 | export type SiteConfig = typeof siteConfig 17 | 18 | export const META_THEME_COLORS = { 19 | light: "#ffffff", 20 | dark: "#09090b", 21 | } 22 | -------------------------------------------------------------------------------- /src/app/theme.css: -------------------------------------------------------------------------------- 1 | /** biome-ignore-all lint/suspicious/noDuplicateCustomProperties: this is intention with @variant */ 2 | 3 | body { 4 | @apply overscroll-none bg-transparent; 5 | } 6 | 7 | :root { 8 | --font-sans: var(--font-mono); 9 | --header-height: calc(var(--spacing) * 12 + 1px); 10 | } 11 | 12 | .theme-scaled { 13 | @media (min-width: 1024px) { 14 | --radius: 0.6rem; 15 | --text-lg: 1.05rem; 16 | --text-base: 0.85rem; 17 | --text-sm: 0.8rem; 18 | --spacing: 0.222222rem; 19 | } 20 | 21 | [data-slot="card"] { 22 | --spacing: 0.16rem; 23 | } 24 | 25 | [data-slot="select-trigger"], 26 | [data-slot="toggle-group-item"] { 27 | --spacing: 0.222222rem; 28 | } 29 | } 30 | 31 | .theme-default, 32 | .theme-default-scaled { 33 | --primary: var(--color-neutral-600); 34 | --primary-foreground: var(--color-neutral-50); 35 | 36 | @variant dark { 37 | --primary: var(--color-neutral-500); 38 | --primary-foreground: var(--color-neutral-50); 39 | } 40 | } 41 | 42 | .theme-blue, 43 | .theme-blue-scaled { 44 | --primary: var(--color-blue-600); 45 | --primary-foreground: var(--color-blue-50); 46 | 47 | @variant dark { 48 | --primary: var(--color-blue-500); 49 | --primary-foreground: var(--color-blue-50); 50 | } 51 | } 52 | 53 | .theme-green, 54 | .theme-green-scaled { 55 | --primary: var(--color-lime-600); 56 | --primary-foreground: var(--color-lime-50); 57 | 58 | @variant dark { 59 | --primary: var(--color-lime-600); 60 | --primary-foreground: var(--color-lime-50); 61 | } 62 | } 63 | 64 | .theme-amber, 65 | .theme-amber-scaled { 66 | --primary: var(--color-amber-600); 67 | --primary-foreground: var(--color-amber-50); 68 | 69 | @variant dark { 70 | --primary: var(--color-amber-500); 71 | --primary-foreground: var(--color-amber-50); 72 | } 73 | } 74 | 75 | .theme-mono, 76 | .theme-mono-scaled { 77 | --font-sans: var(--font-mono); 78 | --primary: var(--color-neutral-600); 79 | --primary-foreground: var(--color-neutral-50); 80 | 81 | @variant dark { 82 | --primary: var(--color-neutral-500); 83 | --primary-foreground: var(--color-neutral-50); 84 | } 85 | 86 | .rounded-xs, 87 | .rounded-sm, 88 | .rounded-md, 89 | .rounded-lg, 90 | .rounded-xl { 91 | @apply !rounded-none; 92 | border-radius: 0; 93 | } 94 | 95 | .shadow-xs, 96 | .shadow-sm, 97 | .shadow-md, 98 | .shadow-lg, 99 | .shadow-xl { 100 | @apply !shadow-none; 101 | } 102 | 103 | [data-slot="toggle-group"], 104 | [data-slot="toggle-group-item"] { 105 | @apply !rounded-none !shadow-none; 106 | } 107 | } 108 | 109 | /* Add scrollbar style to fix layout shift when content changes */ 110 | html { 111 | overflow-y: scroll; 112 | } 113 | -------------------------------------------------------------------------------- /src/components/active-theme.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | createContext, 5 | type ReactNode, 6 | useContext, 7 | useEffect, 8 | useState, 9 | } from "react" 10 | 11 | import { siteConfig } from "@/app/site" 12 | 13 | const COOKIE_NAME = "active_theme" 14 | 15 | function setThemeCookie(theme: string) { 16 | if (typeof window === "undefined") { 17 | return 18 | } 19 | 20 | // biome-ignore lint/suspicious/noDocumentCookie: Should use nextjs lib, but we're migrating to RedwoodJS soon, so not worrying about this 21 | document.cookie = `${COOKIE_NAME}=${theme}; path=/; max-age=31536000; SameSite=Lax; ${window.location.protocol === "https:" ? "Secure;" : ""}` 22 | } 23 | 24 | type ThemeContextType = { 25 | activeTheme: string 26 | setActiveTheme: (theme: string) => void 27 | } 28 | 29 | const ThemeContext = createContext(undefined) 30 | 31 | export function ActiveThemeProvider({ 32 | children, 33 | initialTheme, 34 | }: { 35 | children: ReactNode 36 | initialTheme?: string 37 | }) { 38 | const [activeTheme, setActiveTheme] = useState( 39 | () => initialTheme || siteConfig.defaultTheme, 40 | ) 41 | 42 | useEffect(() => { 43 | setThemeCookie(activeTheme) 44 | 45 | for (const className of document.body.classList) { 46 | if (className.startsWith("theme-")) { 47 | document.body.classList.remove(className) 48 | } 49 | } 50 | document.body.classList.add(`theme-${activeTheme}`) 51 | if (activeTheme.endsWith("-scaled")) { 52 | document.body.classList.add("theme-scaled") 53 | } 54 | }, [activeTheme]) 55 | 56 | return ( 57 | 58 | {children} 59 | 60 | ) 61 | } 62 | 63 | export function useThemeConfig() { 64 | const context = useContext(ThemeContext) 65 | if (context === undefined) { 66 | throw new Error("useThemeConfig must be used within an ActiveThemeProvider") 67 | } 68 | return context 69 | } 70 | -------------------------------------------------------------------------------- /src/components/app-sidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | IconAppWindow, 5 | IconBell, 6 | IconBrandGithub, 7 | IconBulb, 8 | IconDashboard, 9 | IconDatabase, 10 | IconDna, 11 | IconFileWord, 12 | IconHeartbeat, 13 | IconMail, 14 | IconPrismLight, 15 | IconReport, 16 | IconSparkles, 17 | IconTargetArrow, 18 | } from "@tabler/icons-react" 19 | import type * as React from "react" 20 | import SolStatusLogo from "@/components/icons/solstatus-logo" 21 | 22 | import { NavMain } from "@/components/nav-main" 23 | import { NavSecondary } from "@/components/nav-secondary" 24 | import { fontUnboundedCN } from "@/lib/fonts" 25 | import { 26 | Sidebar, 27 | SidebarContent, 28 | SidebarFooter, 29 | SidebarHeader, 30 | SidebarMenu, 31 | SidebarMenuButton, 32 | SidebarMenuItem, 33 | } from "@/registry/new-york-v4/ui/sidebar" 34 | 35 | const data = { 36 | navMain: [ 37 | { 38 | title: "Monitors", 39 | url: "/", 40 | icon: IconDashboard, 41 | items: [ 42 | { 43 | title: "Endpoint", 44 | url: "/", 45 | icon: IconTargetArrow, 46 | }, 47 | { 48 | title: "Synthetic (blocked)", 49 | url: "https://github.com/unibeck/solstatus/issues/19#issuecomment-2878393426", 50 | external: true, 51 | icon: IconAppWindow, 52 | }, 53 | { 54 | title: "Agentic (soon)", 55 | url: "https://github.com/unibeck/solstatus/issues/39", 56 | external: true, 57 | icon: IconSparkles, 58 | }, 59 | { 60 | title: "Heartbeat (soon)", 61 | url: "https://github.com/unibeck/solstatus/issues/43", 62 | external: true, 63 | icon: IconHeartbeat, 64 | }, 65 | { 66 | title: "TCP (soon)", 67 | url: "https://github.com/unibeck/solstatus/issues/44", 68 | external: true, 69 | icon: IconPrismLight, 70 | }, 71 | { 72 | title: "Other? Let me know!", 73 | url: "https://github.com/unibeck/solstatus/issues", 74 | external: true, 75 | icon: IconBulb, 76 | }, 77 | ], 78 | }, 79 | 80 | { 81 | title: "Notifications", 82 | url: "/", 83 | icon: IconBell, 84 | items: [ 85 | { 86 | title: "Opsgenie", 87 | url: "/", 88 | icon: IconBell, 89 | }, 90 | { 91 | title: "Email (soon)", 92 | url: "https://github.com/unibeck/solstatus/issues/47", 93 | external: true, 94 | icon: IconMail, 95 | }, 96 | ], 97 | }, 98 | ], 99 | navSecondary: [ 100 | { 101 | title: "GitHub", 102 | icon: IconBrandGithub, 103 | url: "https://github.com/unibeck/solstatus", 104 | external: true, 105 | }, 106 | { 107 | title: `${process.env.NEXT_PUBLIC_APP_VERSION}`, 108 | icon: IconDna, 109 | }, 110 | ], 111 | documents: [ 112 | { 113 | name: "Data Library", 114 | url: "#", 115 | icon: IconDatabase, 116 | }, 117 | { 118 | name: "Reports", 119 | url: "#", 120 | icon: IconReport, 121 | }, 122 | { 123 | name: "Word Assistant", 124 | url: "#", 125 | icon: IconFileWord, 126 | }, 127 | ], 128 | } 129 | 130 | export function AppSidebar({ ...props }: React.ComponentProps) { 131 | return ( 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | SolStatus 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | {/* */} 150 | 151 | 152 | {/* */} 153 | 154 | ) 155 | } 156 | -------------------------------------------------------------------------------- /src/components/bg-patterns/README.md: -------------------------------------------------------------------------------- 1 | # Source 2 | https://railsdesigner.com/tailwindcss-patterns/ 3 | -------------------------------------------------------------------------------- /src/components/bg-patterns/diagonal-stripes.tsx: -------------------------------------------------------------------------------- 1 | interface DiagonalStripesProps { 2 | className?: string 3 | color?: string 4 | stripeColor?: string 5 | } 6 | 7 | export function DiagonalStripes({ 8 | className = "", 9 | color = "transparent", 10 | stripeColor = "#ffffff33", 11 | }: DiagonalStripesProps) { 12 | return ( 13 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/components/bg-patterns/polka-dots.tsx: -------------------------------------------------------------------------------- 1 | interface PolkaDotsProps { 2 | className?: string 3 | color?: string 4 | dotColor?: string 5 | } 6 | 7 | export function PolkaDots({ 8 | className = "", 9 | color = "transparent", 10 | dotColor = "#ffffff33", 11 | }: PolkaDotsProps) { 12 | return ( 13 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/components/bg-patterns/waves.tsx: -------------------------------------------------------------------------------- 1 | interface WavesProps { 2 | size?: string 3 | className?: string 4 | color?: string 5 | } 6 | 7 | export function Waves({ 8 | size = "20px_20px", 9 | className = "", 10 | color = "#55555533", 11 | }: WavesProps) { 12 | return ( 13 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/components/data-table/column-header.tsx: -------------------------------------------------------------------------------- 1 | import { IconArrowDown, IconArrowsSort, IconArrowUp } from "@tabler/icons-react" 2 | import type { Column } from "@tanstack/react-table" 3 | import type * as React from "react" 4 | import { cn } from "@/lib/utils" 5 | import { Button } from "@/registry/new-york-v4/ui/button" 6 | 7 | interface DataTableColumnHeaderProps 8 | extends React.HTMLAttributes { 9 | column: Column 10 | title: string 11 | } 12 | 13 | export function DataTableColumnHeader({ 14 | column, 15 | title, 16 | className, 17 | }: DataTableColumnHeaderProps) { 18 | "use no memo" 19 | 20 | if (!column.getCanSort()) { 21 | return
{title}
22 | } 23 | 24 | return ( 25 |
26 | 41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/components/data-table/data-row.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import type { Row } from "@tanstack/react-table" 4 | import { flexRender } from "@tanstack/react-table" 5 | import type { z } from "zod" 6 | import type { endpointMonitorsSelectSchema } from "@/db/zod-schema" 7 | import { TableCell, TableRow } from "@/registry/new-york-v4/ui/table" 8 | 9 | interface DataRowProps { 10 | row: Row> 11 | } 12 | 13 | export function DataRow({ row }: DataRowProps) { 14 | "use no memo" 15 | return ( 16 | 20 | {row.getVisibleCells().map((cell) => ( 21 | 22 | {flexRender(cell.column.columnDef.cell, cell.getContext())} 23 | 24 | ))} 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/components/data-table/data-table-loading-overlay.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { IconLoader2 } from "@tabler/icons-react" 4 | 5 | export function DataTableLoadingOverlay() { 6 | return ( 7 |
8 |
9 | 10 | 11 | Loading endpoint Monitors... 12 | 13 |
14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/components/data-table/data-table-skeleton.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Skeleton } from "@/registry/new-york-v4/ui/skeleton" 4 | import { 5 | Table, 6 | TableBody, 7 | TableCell, 8 | TableHead, 9 | TableHeader, 10 | TableRow, 11 | } from "@/registry/new-york-v4/ui/table" 12 | 13 | interface DataTableSkeletonProps { 14 | columnCount?: number 15 | rowCount?: number 16 | showHeader?: boolean 17 | } 18 | 19 | export function DataTableSkeleton({ 20 | columnCount = 7, 21 | rowCount = 10, 22 | showHeader = true, 23 | }: DataTableSkeletonProps) { 24 | return ( 25 |
26 | 27 | {showHeader && ( 28 | 29 | 30 | {Array.from({ length: columnCount }).map((_, index) => ( 31 | 32 | 33 | 34 | ))} 35 | 36 | 37 | )} 38 | 39 | {Array.from({ length: rowCount }).map((_, rowIndex) => ( 40 | 41 | {Array.from({ length: columnCount }).map((_, cellIndex) => ( 42 | 43 | {cellIndex === 0 || cellIndex === 1 ? ( 44 | 45 | ) : cellIndex === 2 ? ( 46 | 47 | ) : cellIndex === 3 ? ( 48 | 49 | ) : cellIndex === columnCount - 1 ? ( 50 | 51 | ) : ( 52 | 53 | )} 54 | 55 | ))} 56 | 57 | ))} 58 | 59 |
60 |
61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /src/components/data-table/endpoint-monitor-actions.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { toast } from "sonner" 4 | import { DEFAULT_TOAST_OPTIONS } from "@/lib/toasts" 5 | 6 | export async function handleResumeMonitoring(endpointMonitorId: string) { 7 | return handleToggleStatus(endpointMonitorId, true) 8 | } 9 | 10 | export async function handlePauseMonitoring(endpointMonitorId: string) { 11 | return handleToggleStatus(endpointMonitorId, false) 12 | } 13 | 14 | export async function handleToggleStatus( 15 | endpointMonitorId: string, 16 | newStatus: boolean, 17 | ) { 18 | if ( 19 | !confirm( 20 | `Are you sure you want to ${newStatus ? "resume" : "pause"} monitoring for this endpoint?`, 21 | ) 22 | ) { 23 | return false 24 | } 25 | 26 | try { 27 | const endpoint = newStatus 28 | ? `/api/endpoint-monitors/${endpointMonitorId}/resume` 29 | : `/api/endpoint-monitors/${endpointMonitorId}/pause` 30 | 31 | const response = await fetch(endpoint, { 32 | method: "POST", 33 | }) 34 | 35 | if (!response.ok) { 36 | throw new Error(`Received status code ${response.status}`) 37 | } 38 | 39 | toast.success( 40 | `Successfully ${newStatus ? "resumed" : "paused"} monitoring`, 41 | { 42 | ...DEFAULT_TOAST_OPTIONS, 43 | }, 44 | ) 45 | 46 | return true 47 | } catch (error) { 48 | console.error("Error toggling status:", error) 49 | toast.error(`Failed to ${newStatus ? "resume" : "pause"} monitoring`, { 50 | description: `${error}`, 51 | ...DEFAULT_TOAST_OPTIONS, 52 | }) 53 | return false 54 | } 55 | } 56 | 57 | export async function handleDeleteWebsite(endpointMonitorId: string) { 58 | if (!confirm("Are you sure you want to delete this endpoint monitor?")) { 59 | return false 60 | } 61 | 62 | try { 63 | const response = await fetch( 64 | `/api/endpoint-monitors/${endpointMonitorId}`, 65 | { 66 | method: "DELETE", 67 | }, 68 | ) 69 | 70 | if (!response.ok) { 71 | throw new Error(`Received status code ${response.status}`) 72 | } 73 | 74 | toast.success("Endpoint monitor deleted successfully", { 75 | ...DEFAULT_TOAST_OPTIONS, 76 | }) 77 | 78 | return true 79 | } catch (error) { 80 | console.error("Error deleting endpointMonitor:", error) 81 | toast.error("Failed to delete endpointMonitor", { 82 | description: `${error}`, 83 | ...DEFAULT_TOAST_OPTIONS, 84 | }) 85 | return false 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/components/data-table/index.tsx: -------------------------------------------------------------------------------- 1 | export { DataTable } from "./data-table" 2 | export { DataTableLoadingOverlay } from "./data-table-loading-overlay" 3 | export { DataTableSkeleton } from "./data-table-skeleton" 4 | -------------------------------------------------------------------------------- /src/components/icons/solstatus-logo.tsx: -------------------------------------------------------------------------------- 1 | interface SolStatusLogoProps extends React.SVGProps { 2 | width?: number 3 | height?: number 4 | } 5 | 6 | const SolStatusLogo = ({ 7 | width = 24, 8 | height = 24, 9 | ...props 10 | }: SolStatusLogoProps) => ( 11 | 18 | SolStatus Logo 19 | 20 | 21 | ) 22 | export default SolStatusLogo 23 | -------------------------------------------------------------------------------- /src/components/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { IconBrightness } from "@tabler/icons-react" 4 | import { useTheme } from "next-themes" 5 | import React from "react" 6 | 7 | import { Button } from "@/registry/new-york-v4/ui/button" 8 | 9 | export function ModeToggle() { 10 | const { setTheme, resolvedTheme } = useTheme() 11 | 12 | const toggleTheme = React.useCallback(() => { 13 | setTheme(resolvedTheme === "dark" ? "light" : "dark") 14 | }, [resolvedTheme, setTheme]) 15 | 16 | return ( 17 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/components/nav-documents.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | type Icon, 5 | IconDots, 6 | IconFolder, 7 | IconShare3, 8 | IconTrash, 9 | } from "@tabler/icons-react" 10 | 11 | import { 12 | DropdownMenu, 13 | DropdownMenuContent, 14 | DropdownMenuItem, 15 | DropdownMenuSeparator, 16 | DropdownMenuTrigger, 17 | } from "@/registry/new-york-v4/ui/dropdown-menu" 18 | import { 19 | SidebarGroup, 20 | SidebarGroupLabel, 21 | SidebarMenu, 22 | SidebarMenuAction, 23 | SidebarMenuButton, 24 | SidebarMenuItem, 25 | useSidebar, 26 | } from "@/registry/new-york-v4/ui/sidebar" 27 | 28 | export function NavDocuments({ 29 | items, 30 | }: { 31 | items: { 32 | name: string 33 | url: string 34 | icon: Icon 35 | }[] 36 | }) { 37 | const { isMobile } = useSidebar() 38 | 39 | return ( 40 | 41 | Documents 42 | 43 | {items.map((item) => ( 44 | 45 | 46 | 47 | 48 | {item.name} 49 | 50 | 51 | 52 | 53 | 57 | 58 | More 59 | 60 | 61 | 66 | 67 | 68 | Open 69 | 70 | 71 | 72 | Share 73 | 74 | 75 | 76 | 77 | Delete 78 | 79 | 80 | 81 | 82 | ))} 83 | 84 | 85 | 86 | More 87 | 88 | 89 | 90 | 91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /src/components/nav-main.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import type { Icon } from "@tabler/icons-react" 4 | 5 | import { 6 | SidebarGroup, 7 | SidebarGroupContent, 8 | SidebarMenu, 9 | SidebarMenuButton, 10 | SidebarMenuItem, 11 | SidebarMenuSub, 12 | SidebarMenuSubButton, 13 | SidebarMenuSubItem, 14 | } from "@/registry/new-york-v4/ui/sidebar" 15 | 16 | export function NavMain({ 17 | items, 18 | }: { 19 | items: { 20 | title: string 21 | url: string 22 | icon?: Icon 23 | items?: { 24 | title: string 25 | url: string 26 | icon: Icon 27 | isActive?: boolean 28 | external?: boolean 29 | }[] 30 | }[] 31 | }) { 32 | return ( 33 | 34 | 35 | 36 | {items.map((item) => ( 37 | 38 | 39 | 40 | {item.title} 41 | 42 | 43 | {item.items?.length ? ( 44 | 45 | {item.items.map((item) => ( 46 | 47 | 48 | 54 | {/* */} 55 | {item.title} 56 | 57 | 58 | 59 | ))} 60 | 61 | ) : null} 62 | 63 | ))} 64 | 65 | {/* {items.map((item) => ( 66 | 67 | 68 | {item.icon && } 69 | {item.title} 70 | 71 | 72 | ))} */} 73 | 74 | 75 | 76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /src/components/nav-secondary.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { type Icon, IconBrightness } from "@tabler/icons-react" 4 | import { useTheme } from "next-themes" 5 | import React from "react" 6 | import { 7 | SidebarGroup, 8 | SidebarGroupContent, 9 | SidebarMenu, 10 | SidebarMenuButton, 11 | SidebarMenuItem, 12 | } from "@/registry/new-york-v4/ui/sidebar" 13 | import { Skeleton } from "@/registry/new-york-v4/ui/skeleton" 14 | import { Switch } from "@/registry/new-york-v4/ui/switch" 15 | 16 | export function NavSecondary({ 17 | items, 18 | ...props 19 | }: { 20 | items: { 21 | title: string 22 | icon: Icon 23 | url?: string 24 | external?: boolean 25 | }[] 26 | } & React.ComponentPropsWithoutRef) { 27 | const { resolvedTheme, setTheme } = useTheme() 28 | const [mounted, setMounted] = React.useState(false) 29 | 30 | React.useEffect(() => { 31 | setMounted(true) 32 | }, []) 33 | 34 | return ( 35 | 36 | 37 | 38 | {items.map((item) => ( 39 | 40 | {item.url ? ( 41 | 42 | 48 | 49 | {item.title} 50 | 51 | 52 | ) : ( 53 | 54 |
55 | 56 | {item.title} 57 |
58 |
59 | )} 60 |
61 | ))} 62 | 63 | 64 | 80 | 81 | 82 |
83 |
84 |
85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /src/components/nav-user.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | IconCreditCard, 5 | IconDotsVertical, 6 | IconLogout, 7 | IconNotification, 8 | IconUserCircle, 9 | } from "@tabler/icons-react" 10 | 11 | import { 12 | Avatar, 13 | AvatarFallback, 14 | AvatarImage, 15 | } from "@/registry/new-york-v4/ui/avatar" 16 | import { 17 | DropdownMenu, 18 | DropdownMenuContent, 19 | DropdownMenuGroup, 20 | DropdownMenuItem, 21 | DropdownMenuLabel, 22 | DropdownMenuSeparator, 23 | DropdownMenuTrigger, 24 | } from "@/registry/new-york-v4/ui/dropdown-menu" 25 | import { 26 | SidebarMenu, 27 | SidebarMenuButton, 28 | SidebarMenuItem, 29 | useSidebar, 30 | } from "@/registry/new-york-v4/ui/sidebar" 31 | 32 | export function NavUser({ 33 | user, 34 | }: { 35 | user: { 36 | name: string 37 | email: string 38 | avatar: string 39 | } 40 | }) { 41 | const { isMobile } = useSidebar() 42 | 43 | return ( 44 | 45 | 46 | 47 | 48 | 52 | 53 | 54 | CN 55 | 56 |
57 | {user.name} 58 | 59 | {user.email} 60 | 61 |
62 | 63 |
64 |
65 | 71 | 72 |
73 | 74 | 75 | CN 76 | 77 |
78 | {user.name} 79 | 80 | {user.email} 81 | 82 |
83 |
84 |
85 | 86 | 87 | 88 | 89 | Account 90 | 91 | 92 | 93 | Billing 94 | 95 | 96 | 97 | Notifications 98 | 99 | 100 | 101 | 102 | 103 | Log out 104 | 105 |
106 |
107 |
108 |
109 | ) 110 | } 111 | -------------------------------------------------------------------------------- /src/components/site-header.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useHeaderContext } from "@/context/header-context" 4 | import { Separator } from "@/registry/new-york-v4/ui/separator" 5 | import { SidebarTrigger } from "@/registry/new-york-v4/ui/sidebar" 6 | 7 | export function SiteHeader() { 8 | const { headerLeftContent, headerRightContent } = useHeaderContext() 9 | return ( 10 |
11 |
12 | 13 | 17 |
{headerLeftContent}
18 |
19 | {headerRightContent} 20 |
21 |
22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | ThemeProvider as NextThemesProvider, 5 | type ThemeProviderProps, 6 | } from "next-themes" 7 | 8 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 9 | return {children} 10 | } 11 | -------------------------------------------------------------------------------- /src/components/theme-selector.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useThemeConfig } from "@/components/active-theme" 4 | import { Label } from "@/registry/new-york-v4/ui/label" 5 | import { 6 | Select, 7 | SelectContent, 8 | SelectGroup, 9 | SelectItem, 10 | SelectLabel, 11 | SelectSeparator, 12 | SelectTrigger, 13 | SelectValue, 14 | } from "@/registry/new-york-v4/ui/select" 15 | 16 | const DEFAULT_THEMES = [ 17 | { 18 | name: "Default", 19 | value: "default", 20 | }, 21 | { 22 | name: "Blue", 23 | value: "blue", 24 | }, 25 | { 26 | name: "Green", 27 | value: "green", 28 | }, 29 | { 30 | name: "Amber", 31 | value: "amber", 32 | }, 33 | ] 34 | 35 | const SCALED_THEMES = [ 36 | { 37 | name: "Default", 38 | value: "default-scaled", 39 | }, 40 | { 41 | name: "Blue", 42 | value: "blue-scaled", 43 | }, 44 | ] 45 | 46 | const MONO_THEMES = [ 47 | { 48 | name: "Mono", 49 | value: "mono-scaled", 50 | }, 51 | ] 52 | 53 | export function ThemeSelector() { 54 | const { activeTheme, setActiveTheme } = useThemeConfig() 55 | 56 | return ( 57 |
58 | 61 | 101 |
102 | ) 103 | } 104 | -------------------------------------------------------------------------------- /src/context/header-context.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import type React from "react" 4 | import { createContext, type ReactNode, useContext, useState } from "react" 5 | 6 | interface HeaderContextProps { 7 | headerLeftContent: React.ReactNode 8 | setHeaderLeftContent: (content: React.ReactNode) => void 9 | headerRightContent: React.ReactNode 10 | setHeaderRightContent: (content: React.ReactNode) => void 11 | } 12 | 13 | const HeaderContext = createContext(undefined) 14 | 15 | export const defaultHeaderContent: React.ReactNode =
16 | 17 | export function HeaderProvider({ children }: { children: ReactNode }) { 18 | const [headerLeftContent, setHeaderLeftContent] = 19 | useState(defaultHeaderContent) 20 | const [headerRightContent, setHeaderRightContent] = 21 | useState(defaultHeaderContent) 22 | 23 | return ( 24 | 32 | {children} 33 | 34 | ) 35 | } 36 | 37 | export function useHeaderContext() { 38 | const context = useContext(HeaderContext) 39 | if (context === undefined) { 40 | throw new Error("useHeaderContext must be used within a HeaderProvider") 41 | } 42 | return context 43 | } 44 | -------------------------------------------------------------------------------- /src/db/index.ts: -------------------------------------------------------------------------------- 1 | import { type DrizzleD1Database, drizzle } from "drizzle-orm/d1" 2 | import { schema } from "./schema" 3 | 4 | export function useDrizzle(D1: D1Database): DrizzleD1Database { 5 | return drizzle(D1, { schema }) 6 | } 7 | 8 | //TODO: Add an error parameter to the function 9 | export const takeUniqueOrThrow = (values: T[]): T => { 10 | if (values.length < 1) { 11 | throw new Error("No values found") 12 | } 13 | if (values.length > 1) { 14 | throw new Error(`Found non unique value of size [${values.length}]`) 15 | } 16 | 17 | const value = values[0] 18 | 19 | if (!value) { 20 | throw new Error("Found nonexistent value") 21 | } 22 | 23 | return value 24 | } 25 | 26 | export const takeFirstOrNull = (values: T[]): T | null => { 27 | if (values.length < 1) { 28 | return null 29 | } 30 | if (values.length > 1) { 31 | throw new Error(`Found non unique value of size [${values.length}]`) 32 | } 33 | 34 | return values[0] 35 | } 36 | -------------------------------------------------------------------------------- /src/db/migrations/0000_fixed_quicksilver.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `uptimeChecks` ( 2 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 3 | `websiteId` text NOT NULL, 4 | `timestamp` integer NOT NULL, 5 | `status` integer, 6 | `responseTime` integer, 7 | `isUp` integer NOT NULL, 8 | FOREIGN KEY (`websiteId`) REFERENCES `websites`(`id`) ON UPDATE no action ON DELETE no action 9 | ); 10 | --> statement-breakpoint 11 | CREATE TABLE `websites` ( 12 | `id` text PRIMARY KEY NOT NULL, 13 | `url` text NOT NULL, 14 | `name` text NOT NULL, 15 | `checkInterval` integer NOT NULL, 16 | `createdAt` integer NOT NULL, 17 | `updatedAt` integer NOT NULL 18 | ); 19 | -------------------------------------------------------------------------------- /src/db/migrations/0001_silent_cyclops.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys=OFF;--> statement-breakpoint 2 | CREATE TABLE `__new_uptimeChecks` ( 3 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 4 | `websiteId` text NOT NULL, 5 | `timestamp` integer NOT NULL, 6 | `status` integer, 7 | `responseTime` integer, 8 | `isUp` integer NOT NULL, 9 | FOREIGN KEY (`websiteId`) REFERENCES `websites`(`id`) ON UPDATE no action ON DELETE cascade 10 | ); 11 | --> statement-breakpoint 12 | INSERT INTO `__new_uptimeChecks`("id", "websiteId", "timestamp", "status", "responseTime", "isUp") SELECT "id", "websiteId", "timestamp", "status", "responseTime", "isUp" FROM `uptimeChecks`;--> statement-breakpoint 13 | DROP TABLE `uptimeChecks`;--> statement-breakpoint 14 | ALTER TABLE `__new_uptimeChecks` RENAME TO `uptimeChecks`;--> statement-breakpoint 15 | PRAGMA foreign_keys=ON; -------------------------------------------------------------------------------- /src/db/migrations/0002_strange_oracle.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `websites` ADD `isRunning` integer DEFAULT true NOT NULL;--> statement-breakpoint 2 | ALTER TABLE `websites` ADD `consecutiveFailures` integer DEFAULT 0 NOT NULL;--> statement-breakpoint 3 | ALTER TABLE `websites` ADD `activeAlert` integer DEFAULT false NOT NULL; -------------------------------------------------------------------------------- /src/db/migrations/0003_young_marvel_boy.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `websites` ADD `expectedStatusCode` integer; -------------------------------------------------------------------------------- /src/db/migrations/0004_warm_lady_bullseye.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `uptimeChecks` ADD `isExpectedStatus` integer NOT NULL DEFAULT 1; -------------------------------------------------------------------------------- /src/db/migrations/0005_strange-cyclops.sql: -------------------------------------------------------------------------------- 1 | -- Custom SQL migration file, put your code below! -- 2 | UPDATE "uptimeChecks" SET "isExpectedStatus" = "isUp"; 3 | -------------------------------------------------------------------------------- /src/db/migrations/0006_rich_brother_voodoo.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `uptimeChecks` DROP COLUMN `isUp`; -------------------------------------------------------------------------------- /src/db/migrations/0007_young_sue_storm.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `endpointMonitors` ( 2 | `id` text PRIMARY KEY NOT NULL, 3 | `url` text NOT NULL, 4 | `name` text NOT NULL, 5 | `checkInterval` integer NOT NULL, 6 | `isRunning` integer DEFAULT true NOT NULL, 7 | `expectedStatusCode` integer, 8 | `consecutiveFailures` integer DEFAULT 0 NOT NULL, 9 | `activeAlert` integer DEFAULT false NOT NULL, 10 | `createdAt` integer NOT NULL, 11 | `updatedAt` integer NOT NULL 12 | ); 13 | -------------------------------------------------------------------------------- /src/db/migrations/0008_common_psylocke.sql: -------------------------------------------------------------------------------- 1 | -- Custom SQL migration file, put your code below! -- 2 | 3 | -- Step 1: Copy all rows from websites to endpointMonitors 4 | INSERT INTO endpointMonitors ( 5 | id, 6 | url, 7 | name, 8 | checkInterval, 9 | isRunning, 10 | expectedStatusCode, 11 | consecutiveFailures, 12 | activeAlert, 13 | createdAt, 14 | updatedAt 15 | ) 16 | SELECT 17 | id, 18 | url, 19 | name, 20 | checkInterval, 21 | isRunning, 22 | expectedStatusCode, 23 | consecutiveFailures, 24 | activeAlert, 25 | createdAt, 26 | updatedAt 27 | FROM websites; 28 | 29 | -- Step 2: Update the IDs in endpointMonitors to replace 'webs_' with 'endp_' 30 | UPDATE endpointMonitors 31 | SET id = REPLACE(id, 'webs_', 'endp_') 32 | WHERE id LIKE 'webs_%'; 33 | -------------------------------------------------------------------------------- /src/db/migrations/0009_many_raza.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys=OFF;--> statement-breakpoint 2 | CREATE TABLE `__new_uptimeChecks` ( 3 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 4 | `endpointMonitorId` text NOT NULL, 5 | `timestamp` integer NOT NULL, 6 | `status` integer, 7 | `responseTime` integer, 8 | `isExpectedStatus` integer NOT NULL, 9 | FOREIGN KEY (`endpointMonitorId`) REFERENCES `endpointMonitors`(`id`) ON UPDATE no action ON DELETE cascade 10 | ); 11 | --> statement-breakpoint 12 | -- Insert only valid rows with updated endpointMonitorId 13 | INSERT INTO `__new_uptimeChecks` ( 14 | "id", 15 | "endpointMonitorId", 16 | "timestamp", 17 | "status", 18 | "responseTime", 19 | "isExpectedStatus" 20 | ) 21 | SELECT 22 | uc."id", 23 | REPLACE(uc."websiteId", 'webs_', 'endp_'), -- Select from websiteId and use the corrected ID 24 | uc."timestamp", 25 | uc."status", 26 | uc."responseTime", 27 | uc."isExpectedStatus" 28 | FROM 29 | `uptimeChecks` uc 30 | WHERE 31 | -- Ensure the corresponding monitor exists in endpointMonitors 32 | EXISTS ( 33 | SELECT 1 34 | FROM endpointMonitors em 35 | WHERE em.id = REPLACE(uc."websiteId", 'webs_', 'endp_') -- Check existence using websiteId 36 | ); 37 | --> statement-breakpoint 38 | DROP TABLE `uptimeChecks`;--> statement-breakpoint 39 | ALTER TABLE `__new_uptimeChecks` RENAME TO `uptimeChecks`;--> statement-breakpoint 40 | PRAGMA foreign_keys=ON; -------------------------------------------------------------------------------- /src/db/migrations/0010_dashing_major_mapleleaf.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE `websites`; -------------------------------------------------------------------------------- /src/db/migrations/0011_smiling_cassandra_nova.sql: -------------------------------------------------------------------------------- 1 | -- Step 1: Add alertThreshold column as nullable 2 | ALTER TABLE `endpointMonitors` ADD COLUMN `alertThreshold` integer;--> statement-breakpoint 3 | 4 | -- Step 2: Set all existing endpoint monitor alertThreshold values to 2 5 | UPDATE `endpointMonitors` SET `alertThreshold` = 2;--> statement-breakpoint 6 | 7 | -- Step 3: Update column to non-nullable with default value of 2 8 | PRAGMA foreign_keys=OFF;--> statement-breakpoint 9 | CREATE TABLE `__new_endpointMonitors` ( 10 | `id` text PRIMARY KEY NOT NULL, 11 | `url` text NOT NULL, 12 | `name` text NOT NULL, 13 | `checkInterval` integer NOT NULL, 14 | `isRunning` integer DEFAULT true NOT NULL, 15 | `expectedStatusCode` integer, 16 | `consecutiveFailures` integer DEFAULT 0 NOT NULL, 17 | `alertThreshold` integer DEFAULT 2 NOT NULL, 18 | `activeAlert` integer DEFAULT false NOT NULL, 19 | `createdAt` integer NOT NULL, 20 | `updatedAt` integer NOT NULL 21 | );--> statement-breakpoint 22 | 23 | INSERT INTO `__new_endpointMonitors` ( 24 | `id`, 25 | `url`, 26 | `name`, 27 | `checkInterval`, 28 | `isRunning`, 29 | `expectedStatusCode`, 30 | `consecutiveFailures`, 31 | `alertThreshold`, 32 | `activeAlert`, 33 | `createdAt`, 34 | `updatedAt` 35 | ) 36 | SELECT 37 | `id`, 38 | `url`, 39 | `name`, 40 | `checkInterval`, 41 | `isRunning`, 42 | `expectedStatusCode`, 43 | `consecutiveFailures`, 44 | `alertThreshold`, 45 | `activeAlert`, 46 | `createdAt`, 47 | `updatedAt` 48 | FROM `endpointMonitors`;--> statement-breakpoint 49 | 50 | DROP TABLE `endpointMonitors`;--> statement-breakpoint 51 | ALTER TABLE `__new_endpointMonitors` RENAME TO `endpointMonitors`;--> statement-breakpoint 52 | PRAGMA foreign_keys=ON; 53 | -------------------------------------------------------------------------------- /src/db/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "5ec70010-9baa-4b14-9fd7-478cd6db38ca", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "uptimeChecks": { 8 | "name": "uptimeChecks", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "websiteId": { 18 | "name": "websiteId", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "timestamp": { 25 | "name": "timestamp", 26 | "type": "integer", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | }, 31 | "status": { 32 | "name": "status", 33 | "type": "integer", 34 | "primaryKey": false, 35 | "notNull": false, 36 | "autoincrement": false 37 | }, 38 | "responseTime": { 39 | "name": "responseTime", 40 | "type": "integer", 41 | "primaryKey": false, 42 | "notNull": false, 43 | "autoincrement": false 44 | }, 45 | "isUp": { 46 | "name": "isUp", 47 | "type": "integer", 48 | "primaryKey": false, 49 | "notNull": true, 50 | "autoincrement": false 51 | } 52 | }, 53 | "indexes": {}, 54 | "foreignKeys": { 55 | "uptimeChecks_websiteId_websites_id_fk": { 56 | "name": "uptimeChecks_websiteId_websites_id_fk", 57 | "tableFrom": "uptimeChecks", 58 | "tableTo": "websites", 59 | "columnsFrom": [ 60 | "websiteId" 61 | ], 62 | "columnsTo": [ 63 | "id" 64 | ], 65 | "onDelete": "no action", 66 | "onUpdate": "no action" 67 | } 68 | }, 69 | "compositePrimaryKeys": {}, 70 | "uniqueConstraints": {}, 71 | "checkConstraints": {} 72 | }, 73 | "websites": { 74 | "name": "websites", 75 | "columns": { 76 | "id": { 77 | "name": "id", 78 | "type": "text", 79 | "primaryKey": true, 80 | "notNull": true, 81 | "autoincrement": false 82 | }, 83 | "url": { 84 | "name": "url", 85 | "type": "text", 86 | "primaryKey": false, 87 | "notNull": true, 88 | "autoincrement": false 89 | }, 90 | "name": { 91 | "name": "name", 92 | "type": "text", 93 | "primaryKey": false, 94 | "notNull": true, 95 | "autoincrement": false 96 | }, 97 | "checkInterval": { 98 | "name": "checkInterval", 99 | "type": "integer", 100 | "primaryKey": false, 101 | "notNull": true, 102 | "autoincrement": false 103 | }, 104 | "createdAt": { 105 | "name": "createdAt", 106 | "type": "integer", 107 | "primaryKey": false, 108 | "notNull": true, 109 | "autoincrement": false 110 | }, 111 | "updatedAt": { 112 | "name": "updatedAt", 113 | "type": "integer", 114 | "primaryKey": false, 115 | "notNull": true, 116 | "autoincrement": false 117 | } 118 | }, 119 | "indexes": {}, 120 | "foreignKeys": {}, 121 | "compositePrimaryKeys": {}, 122 | "uniqueConstraints": {}, 123 | "checkConstraints": {} 124 | } 125 | }, 126 | "views": {}, 127 | "enums": {}, 128 | "_meta": { 129 | "schemas": {}, 130 | "tables": {}, 131 | "columns": {} 132 | }, 133 | "internal": { 134 | "indexes": {} 135 | } 136 | } -------------------------------------------------------------------------------- /src/db/migrations/meta/0001_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "927ec599-f173-4411-bb1e-c7407ec0a69b", 5 | "prevId": "5ec70010-9baa-4b14-9fd7-478cd6db38ca", 6 | "tables": { 7 | "uptimeChecks": { 8 | "name": "uptimeChecks", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "websiteId": { 18 | "name": "websiteId", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "timestamp": { 25 | "name": "timestamp", 26 | "type": "integer", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | }, 31 | "status": { 32 | "name": "status", 33 | "type": "integer", 34 | "primaryKey": false, 35 | "notNull": false, 36 | "autoincrement": false 37 | }, 38 | "responseTime": { 39 | "name": "responseTime", 40 | "type": "integer", 41 | "primaryKey": false, 42 | "notNull": false, 43 | "autoincrement": false 44 | }, 45 | "isUp": { 46 | "name": "isUp", 47 | "type": "integer", 48 | "primaryKey": false, 49 | "notNull": true, 50 | "autoincrement": false 51 | } 52 | }, 53 | "indexes": {}, 54 | "foreignKeys": { 55 | "uptimeChecks_websiteId_websites_id_fk": { 56 | "name": "uptimeChecks_websiteId_websites_id_fk", 57 | "tableFrom": "uptimeChecks", 58 | "tableTo": "websites", 59 | "columnsFrom": [ 60 | "websiteId" 61 | ], 62 | "columnsTo": [ 63 | "id" 64 | ], 65 | "onDelete": "cascade", 66 | "onUpdate": "no action" 67 | } 68 | }, 69 | "compositePrimaryKeys": {}, 70 | "uniqueConstraints": {}, 71 | "checkConstraints": {} 72 | }, 73 | "websites": { 74 | "name": "websites", 75 | "columns": { 76 | "id": { 77 | "name": "id", 78 | "type": "text", 79 | "primaryKey": true, 80 | "notNull": true, 81 | "autoincrement": false 82 | }, 83 | "url": { 84 | "name": "url", 85 | "type": "text", 86 | "primaryKey": false, 87 | "notNull": true, 88 | "autoincrement": false 89 | }, 90 | "name": { 91 | "name": "name", 92 | "type": "text", 93 | "primaryKey": false, 94 | "notNull": true, 95 | "autoincrement": false 96 | }, 97 | "checkInterval": { 98 | "name": "checkInterval", 99 | "type": "integer", 100 | "primaryKey": false, 101 | "notNull": true, 102 | "autoincrement": false 103 | }, 104 | "createdAt": { 105 | "name": "createdAt", 106 | "type": "integer", 107 | "primaryKey": false, 108 | "notNull": true, 109 | "autoincrement": false 110 | }, 111 | "updatedAt": { 112 | "name": "updatedAt", 113 | "type": "integer", 114 | "primaryKey": false, 115 | "notNull": true, 116 | "autoincrement": false 117 | } 118 | }, 119 | "indexes": {}, 120 | "foreignKeys": {}, 121 | "compositePrimaryKeys": {}, 122 | "uniqueConstraints": {}, 123 | "checkConstraints": {} 124 | } 125 | }, 126 | "views": {}, 127 | "enums": {}, 128 | "_meta": { 129 | "schemas": {}, 130 | "tables": {}, 131 | "columns": {} 132 | }, 133 | "internal": { 134 | "indexes": {} 135 | } 136 | } -------------------------------------------------------------------------------- /src/db/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1741787632688, 9 | "tag": "0000_fixed_quicksilver", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "6", 15 | "when": 1741808328762, 16 | "tag": "0001_silent_cyclops", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "6", 22 | "when": 1741901313470, 23 | "tag": "0002_strange_oracle", 24 | "breakpoints": true 25 | }, 26 | { 27 | "idx": 3, 28 | "version": "6", 29 | "when": 1744045124185, 30 | "tag": "0003_young_marvel_boy", 31 | "breakpoints": true 32 | }, 33 | { 34 | "idx": 4, 35 | "version": "6", 36 | "when": 1745169570476, 37 | "tag": "0004_warm_lady_bullseye", 38 | "breakpoints": true 39 | }, 40 | { 41 | "idx": 5, 42 | "version": "6", 43 | "when": 1745171270565, 44 | "tag": "0005_strange-cyclops", 45 | "breakpoints": true 46 | }, 47 | { 48 | "idx": 6, 49 | "version": "6", 50 | "when": 1745171470586, 51 | "tag": "0006_rich_brother_voodoo", 52 | "breakpoints": true 53 | }, 54 | { 55 | "idx": 7, 56 | "version": "6", 57 | "when": 1745341218145, 58 | "tag": "0007_young_sue_storm", 59 | "breakpoints": true 60 | }, 61 | { 62 | "idx": 8, 63 | "version": "6", 64 | "when": 1745341226484, 65 | "tag": "0008_common_psylocke", 66 | "breakpoints": true 67 | }, 68 | { 69 | "idx": 9, 70 | "version": "6", 71 | "when": 1745342037836, 72 | "tag": "0009_many_raza", 73 | "breakpoints": true 74 | }, 75 | { 76 | "idx": 10, 77 | "version": "6", 78 | "when": 1745350463159, 79 | "tag": "0010_dashing_major_mapleleaf", 80 | "breakpoints": true 81 | }, 82 | { 83 | "idx": 11, 84 | "version": "6", 85 | "when": 1747874524852, 86 | "tag": "0011_smiling_cassandra_nova", 87 | "breakpoints": true 88 | } 89 | ] 90 | } -------------------------------------------------------------------------------- /src/db/schema/endpointMonitor.ts: -------------------------------------------------------------------------------- 1 | import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core" 2 | import { timestamps } from "@/db/schema/utils" 3 | 4 | export const EndpointMonitorsTable = sqliteTable("endpointMonitors", { 5 | id: text("id").primaryKey(), 6 | url: text("url").notNull(), 7 | name: text("name").notNull(), 8 | checkInterval: integer("checkInterval").notNull(), 9 | isRunning: integer("isRunning", { mode: "boolean" }).notNull().default(true), 10 | expectedStatusCode: integer("expectedStatusCode"), 11 | consecutiveFailures: integer("consecutiveFailures").notNull().default(0), 12 | alertThreshold: integer("alertThreshold").notNull().default(2), 13 | activeAlert: integer("activeAlert", { mode: "boolean" }) 14 | .notNull() 15 | .default(false), 16 | 17 | ...timestamps, 18 | }) 19 | 20 | export const UptimeChecksTable = sqliteTable("uptimeChecks", { 21 | id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), 22 | endpointMonitorId: text("endpointMonitorId") 23 | .notNull() 24 | .references(() => EndpointMonitorsTable.id, { onDelete: "cascade" }), 25 | timestamp: integer("timestamp", { mode: "timestamp" }).notNull(), 26 | status: integer("status"), 27 | responseTime: integer("responseTime"), 28 | isExpectedStatus: integer("isExpectedStatus", { mode: "boolean" }).notNull(), 29 | }) 30 | -------------------------------------------------------------------------------- /src/db/schema/index.ts: -------------------------------------------------------------------------------- 1 | import { EndpointMonitorsTable, UptimeChecksTable } from "./endpointMonitor" 2 | 3 | export * from "./endpointMonitor" 4 | 5 | export const schema = { 6 | EndpointMonitorsTable, 7 | UptimeChecksTable, 8 | } 9 | -------------------------------------------------------------------------------- /src/db/schema/utils.ts: -------------------------------------------------------------------------------- 1 | import { integer } from "drizzle-orm/sqlite-core" 2 | 3 | export const timestamps = { 4 | createdAt: integer("createdAt", { mode: "timestamp" }) 5 | .notNull() 6 | .$default(() => new Date()), 7 | updatedAt: integer("updatedAt", { mode: "timestamp" }) 8 | .notNull() 9 | .$default(() => new Date()) 10 | .$onUpdate(() => new Date()), 11 | } 12 | -------------------------------------------------------------------------------- /src/db/zod-schema.ts: -------------------------------------------------------------------------------- 1 | import { createSchemaFactory } from "drizzle-zod" 2 | import { z } from "zod" 3 | import { EndpointMonitorsTable, UptimeChecksTable } from "@/db/schema" 4 | 5 | const { createInsertSchema } = createSchemaFactory({ 6 | // This configuration will only coerce dates. Set `coerce` to `true` to coerce all data types or specify others 7 | coerce: { 8 | date: true, 9 | }, 10 | }) 11 | 12 | const { createSelectSchema } = createSchemaFactory({ 13 | // This configuration will only coerce dates. Set `coerce` to `true` to coerce all data types or specify others 14 | coerce: { 15 | date: true, 16 | }, 17 | }) 18 | 19 | export const endpointMonitorsInsertSchema = createInsertSchema( 20 | EndpointMonitorsTable, 21 | { 22 | url: (schema) => schema.url(), 23 | expectedStatusCode: z.number().positive().int().optional(), 24 | alertThreshold: z.number().positive().int(), 25 | }, 26 | ).omit({ 27 | createdAt: true, 28 | updatedAt: true, 29 | }) 30 | 31 | export const endpointMonitorsInsertDTOSchema = 32 | endpointMonitorsInsertSchema.omit({ 33 | id: true, 34 | }) 35 | 36 | export const endpointMonitorsSelectSchema = createSelectSchema( 37 | EndpointMonitorsTable, 38 | ) 39 | 40 | export const endpointMonitorsPatchSchema = createInsertSchema( 41 | EndpointMonitorsTable, 42 | ).partial() 43 | 44 | export const uptimeChecksInsertSchema = createInsertSchema( 45 | UptimeChecksTable, 46 | ).omit({ 47 | id: true, 48 | }) 49 | 50 | export const uptimeChecksSelectSchema = createSelectSchema(UptimeChecksTable) 51 | 52 | export const uptimeChecksPatchSchema = 53 | createInsertSchema(UptimeChecksTable).partial() 54 | -------------------------------------------------------------------------------- /src/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unibeck/solstatus/143d3a89a2c44525dde636268c6cd90fcf183d78/src/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /src/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unibeck/solstatus/143d3a89a2c44525dde636268c6cd90fcf183d78/src/fonts/GeistVF.woff -------------------------------------------------------------------------------- /src/hooks/use-mobile.ts: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | const MOBILE_BREAKPOINT = 768 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState(undefined) 7 | 8 | React.useEffect(() => { 9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) 10 | const onChange = () => { 11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 12 | } 13 | mql.addEventListener("change", onChange) 14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 15 | return () => mql.removeEventListener("change", onChange) 16 | }, []) 17 | 18 | return !!isMobile 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/api-utils.ts: -------------------------------------------------------------------------------- 1 | import { createZodRoute } from "next-zod-route" 2 | 3 | import { INTERNAL_SERVER_ERROR } from "stoker/http-status-codes" 4 | import { logError, logErrorStack } from "./errors" 5 | 6 | export const createRoute = createZodRoute({ 7 | handleServerError: (error: Error) => { 8 | const errorMessage = logError(error) 9 | logErrorStack(error) 10 | 11 | // TODO: Create custom error that takes message, error, and status code 12 | // if (error instanceof CustomError) { 13 | // return new Response(JSON.stringify({ message: error.message }), { status: error.status }); 14 | // } 15 | 16 | // Default error response 17 | return new Response( 18 | JSON.stringify({ 19 | message: INTERNAL_SERVER_ERROR, 20 | error: errorMessage, 21 | }), 22 | { status: INTERNAL_SERVER_ERROR }, 23 | ) 24 | }, 25 | }) 26 | -------------------------------------------------------------------------------- /src/lib/app-env.ts: -------------------------------------------------------------------------------- 1 | import { PRE_FQDN, PROD_FQDN } from "@/lib/constants" 2 | 3 | const DEV: AppEnvMetadata = { 4 | appUrl: "http://localhost:8787", 5 | } 6 | 7 | const PRE: AppEnvMetadata = { 8 | ...DEV, 9 | 10 | appUrl: PRE_FQDN, 11 | } 12 | 13 | const PROD: AppEnvMetadata = { 14 | ...PRE, 15 | 16 | appUrl: PROD_FQDN, 17 | } 18 | 19 | export enum AppEnvID { 20 | DEV = "development", 21 | PRE = "preview", 22 | PROD = "production", 23 | } 24 | 25 | export interface AppEnvMetadata { 26 | appUrl: string 27 | } 28 | 29 | const AppEnvs: { [value in AppEnvID]: AppEnvMetadata } = { 30 | [AppEnvID.DEV]: DEV, 31 | [AppEnvID.PRE]: PRE, 32 | [AppEnvID.PROD]: PROD, 33 | } 34 | 35 | export function getAppEnvID(): AppEnvID { 36 | console.log(`Getting app env ID for [${process.env.NEXT_PUBLIC_APP_ENV}]`) 37 | return getAppEnvIDFromStr(process.env.NEXT_PUBLIC_APP_ENV || "development") 38 | } 39 | 40 | export function getAppEnvIDFromStr(appEnvStr: string): AppEnvID { 41 | switch (appEnvStr.toLowerCase()) { 42 | case "development": 43 | return AppEnvID.DEV 44 | case "preview": 45 | return AppEnvID.PRE 46 | case "production": 47 | return AppEnvID.PROD 48 | default: 49 | throw new Error(`Unknown environment: ${appEnvStr}`) 50 | } 51 | } 52 | 53 | export function getAppEnvMetadata(appEnvId = getAppEnvID()): AppEnvMetadata { 54 | return AppEnvs[appEnvId] 55 | } 56 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | import { NOT_FOUND as NOT_FOUND_PHRASE } from "stoker/http-status-phrases" 2 | import { createMessageObjectSchema } from "stoker/openapi/schemas" 3 | import { z } from "zod" 4 | 5 | export const PRE_FQDN = "UPDATE_ME_PRE_FQDN" 6 | export const PROD_FQDN = "UPDATE_ME_PROD_FQDN" 7 | 8 | export const ZOD_ERROR_MESSAGES = { 9 | REQUIRED: "Required", 10 | EXPECTED_NUMBER: "Expected number, received nan", 11 | NO_UPDATES: "No updates provided", 12 | } 13 | 14 | export const ZOD_ERROR_CODES = { 15 | INVALID_UPDATES: "invalid_updates", 16 | } 17 | 18 | export const NotFoundSchema = createMessageObjectSchema(NOT_FOUND_PHRASE) 19 | 20 | export const ErrorResponseSchema = z.object({ 21 | error: z.string(), 22 | }) 23 | -------------------------------------------------------------------------------- /src/lib/errors.ts: -------------------------------------------------------------------------------- 1 | export function getErrorMessage(error: unknown): string { 2 | if (error && typeof error === "object" && "message" in error) { 3 | return (error as { message: string }).message 4 | } 5 | return String(error) 6 | } 7 | 8 | export function getErrorStack(error: unknown): string | undefined { 9 | if (error instanceof Error) { 10 | return error.stack 11 | } 12 | 13 | return undefined 14 | } 15 | 16 | export function logError(error: unknown): string { 17 | const message = getErrorMessage(error) 18 | console.error(message) 19 | return message 20 | } 21 | 22 | export function logErrorStack(error: unknown) { 23 | const stack = getErrorStack(error) 24 | if (stack) { 25 | console.error(stack) 26 | } 27 | } 28 | 29 | /** 30 | * Error thrown when the MonitorTrigger Durable Object is accessed before being initialized. 31 | */ 32 | // export const MonitorTriggerNotInitializedName = "MonitorTriggerNotInitializedError" 33 | export class MonitorTriggerNotInitializedError extends Error { 34 | static readonly NAME = "MonitorTriggerNotInitializedError" 35 | 36 | constructor( 37 | message = "MonitorTrigger Durable Object accessed before initialization.", 38 | ) { 39 | super(message) 40 | this.name = MonitorTriggerNotInitializedError.NAME 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/fonts.ts: -------------------------------------------------------------------------------- 1 | import { Geist, Geist_Mono, Unbounded } from "next/font/google" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const fontSans = Geist({ 6 | subsets: ["latin"], 7 | variable: "--font-sans", 8 | }) 9 | 10 | const fontMono = Geist_Mono({ 11 | subsets: ["latin"], 12 | variable: "--font-mono", 13 | }) 14 | 15 | const fontUnbounded = Unbounded({ 16 | subsets: ["latin"], 17 | variable: "--font-unbounded", 18 | }) 19 | export const fontUnboundedCN = fontUnbounded.className 20 | 21 | export const fontVariables = cn( 22 | fontSans.variable, 23 | fontMono.variable, 24 | fontUnbounded.variable, 25 | ) 26 | -------------------------------------------------------------------------------- /src/lib/formatters.ts: -------------------------------------------------------------------------------- 1 | import type { z } from "zod" 2 | import type { endpointMonitorsSelectSchema } from "@/db/zod-schema" 3 | 4 | export function msToHumanReadable( 5 | ms: number, 6 | short = false, 7 | toFixed = 2, 8 | ): string { 9 | if (ms < 1000) { 10 | return `${ms.toFixed(0)}${short ? "" : " "}${short ? "ms" : "milliseconds"}` 11 | } 12 | 13 | return secsToHumanReadable(ms / 1000, short, toFixed) 14 | } 15 | 16 | export function secsToHumanReadable( 17 | seconds: number, 18 | short = false, 19 | toFixed = 0, 20 | ): string { 21 | if (seconds < 60) { 22 | return `${Number.parseFloat(seconds.toFixed(toFixed))}${short ? "" : " "}${short ? "s" : "seconds"}` 23 | } 24 | 25 | const minutes = Math.floor(seconds / 60) 26 | if (minutes < 60) { 27 | return `${Number.parseFloat(minutes.toFixed(toFixed))}${short ? "" : " "}${short ? "m" : "minutes"}` 28 | } 29 | 30 | const hours = Math.floor(minutes / 60) 31 | if (hours < 24) { 32 | return `${Number.parseFloat(hours.toFixed(toFixed))}${short ? "" : " "}${short ? "h" : "hours"}` 33 | } 34 | 35 | const days = Math.floor(hours / 24) 36 | return `${Number.parseFloat(days.toFixed(toFixed))}${short ? "" : " "}${short ? "d" : "days"}` 37 | } 38 | 39 | export function endpointSignature( 40 | endpointMonitor: z.infer, 41 | ): string { 42 | return `[${endpointMonitor.id}](${endpointMonitor.url})` 43 | } 44 | -------------------------------------------------------------------------------- /src/lib/ids.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from "nanoid" 2 | 3 | export const nanoid = customAlphabet( 4 | "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz", 5 | ) 6 | 7 | export enum PRE_ID { 8 | endpointMonitor = "endp", 9 | uptimeCheck = "uptc", 10 | syntheticMonitor = "synm", 11 | } 12 | 13 | export const createId = (prefix: PRE_ID) => [prefix, nanoid(20)].join("_") 14 | -------------------------------------------------------------------------------- /src/lib/opsgenie.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Opsgenie API integration for creating alerts 3 | * 4 | * Documentation: https://docs.opsgenie.com/docs/alert-api 5 | */ 6 | 7 | interface OpsgenieAlertPayload { 8 | message: string 9 | description?: string 10 | alias?: string 11 | responders?: Array<{ 12 | id?: string 13 | username?: string 14 | name?: string 15 | type: "user" | "team" | "escalation" | "schedule" 16 | }> 17 | visibleTo?: Array<{ 18 | id?: string 19 | username?: string 20 | name?: string 21 | type: "user" | "team" 22 | }> 23 | actions?: string[] 24 | tags?: string[] 25 | details?: Record 26 | entity?: string 27 | source?: string 28 | priority?: "P1" | "P2" | "P3" | "P4" | "P5" 29 | user?: string 30 | note?: string 31 | } 32 | 33 | interface OpsgenieAlertResponse { 34 | result: string 35 | took: number 36 | requestId: string 37 | message?: string 38 | } 39 | 40 | /** 41 | * Send an alert to Opsgenie 42 | * 43 | * @param apiKey - Opsgenie API key 44 | * @param payload - Alert payload 45 | * @returns Response from Opsgenie API 46 | */ 47 | export async function sendOpsgenieAlert( 48 | apiKey: string, 49 | payload: OpsgenieAlertPayload, 50 | ): Promise { 51 | try { 52 | const response = await fetch("https://api.opsgenie.com/v2/alerts", { 53 | method: "POST", 54 | headers: { 55 | "Content-Type": "application/json", 56 | Authorization: `GenieKey ${apiKey}`, 57 | }, 58 | body: JSON.stringify(payload), 59 | }) 60 | 61 | if (!response.ok) { 62 | const errorText = await response.text() 63 | console.error(`Opsgenie API error (${response.status}): ${errorText}`) 64 | return null 65 | } 66 | 67 | return await response.json() 68 | } catch (error) { 69 | console.error("Error sending alert to Opsgenie:", error) 70 | return null 71 | } 72 | } 73 | 74 | /** 75 | * Create an alert for a failed endpointMonitor check 76 | * 77 | * @param apiKey - Opsgenie API key 78 | * @param endpointMonitorName - Name of the endpointMonitor that failed 79 | * @param endpointMonitorUrl - URL of the endpointMonitor that failed 80 | * @param status - HTTP status code (if any) 81 | * @param error - Error message (if any) 82 | * @returns Response from Opsgenie API 83 | */ 84 | export async function createEndpointMonitorDownAlert( 85 | apiKey: string, 86 | endpointMonitorName: string, 87 | endpointMonitorUrl: string, 88 | status?: number, 89 | error?: string, 90 | ): Promise { 91 | const message = `Endpoint Monitor Down: ${endpointMonitorName}` 92 | 93 | const description = status 94 | ? `Endpoint Monitor ${endpointMonitorName} (${endpointMonitorUrl}) is down with status code ${status}.` 95 | : `Endpoint Monitor ${endpointMonitorName} (${endpointMonitorUrl}) is down. ${error || ""}` 96 | 97 | return sendOpsgenieAlert(apiKey, { 98 | message, 99 | description, 100 | alias: `endpointMonitor-down-${endpointMonitorUrl.replace(/[^a-zA-Z0-9]/g, "-")}`, 101 | priority: "P2", 102 | tags: ["solstatus", "downtime"], 103 | entity: endpointMonitorUrl, 104 | source: "SolStatus", 105 | details: { 106 | endpointMonitor: endpointMonitorName, 107 | url: endpointMonitorUrl, 108 | status: status?.toString() || "N/A", 109 | error: error || "", 110 | monitorRepo: "https://github.com/unibeck/solstatus", 111 | // cloudflareWorkerDashboard: 112 | // "https://dash.cloudflare.com/UPDATE_ME_ABC", 113 | // cloudflareMonitorDODashboard: 114 | // "https://dash.cloudflare.com/UPDATE_ME_ABC", 115 | }, 116 | }) 117 | } 118 | -------------------------------------------------------------------------------- /src/lib/route-schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod" 2 | 3 | /** 4 | * General params schemas 5 | */ 6 | export const idNumParamsSchema = z.object({ 7 | id: z.number(), 8 | }) 9 | 10 | export const idStringParamsSchema = z.object({ 11 | id: z.string(), 12 | }) 13 | 14 | export const paginationQuerySchema = ( 15 | orderBy = "createdAt", 16 | order: "asc" | "desc" = "desc", 17 | ) => 18 | z.object({ 19 | pageSize: z.number({ coerce: true }).optional().default(10), 20 | page: z.number({ coerce: true }).optional().default(0), 21 | orderBy: z.string().optional().default(orderBy), 22 | order: z.enum(["asc", "desc"]).optional().default(order), 23 | }) 24 | 25 | /** 26 | * Specific query schemas 27 | */ 28 | export const daysQuerySchema = (defaultDays = 1) => 29 | z.object({ 30 | days: z.number({ coerce: true }).optional().default(defaultDays), 31 | }) 32 | 33 | export const timeRangeQuerySchema = z.object({ 34 | timeRange: z.enum(["1h", "1d", "7d"]).optional().default("1d"), 35 | }) 36 | -------------------------------------------------------------------------------- /src/lib/toasts.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_TOAST_OPTIONS = { 2 | richColors: true, 3 | // position: "bottom-center" as const, 4 | } 5 | -------------------------------------------------------------------------------- /src/lib/uptime-utils.ts: -------------------------------------------------------------------------------- 1 | import type { TimeRange } from "@/types/endpointMonitor" 2 | 3 | export function formatTimeLabel( 4 | timestamp: number, 5 | timeRange: TimeRange | "24h" | "30d", 6 | detailed = false, 7 | ): string { 8 | const date = new Date(timestamp) 9 | 10 | if (timeRange === "1h") { 11 | return detailed 12 | ? date.toLocaleTimeString([], { 13 | hour: "2-digit", 14 | minute: "2-digit", 15 | second: "2-digit", 16 | }) 17 | : date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) 18 | } 19 | 20 | if (timeRange === "1d" || timeRange === "24h") { 21 | return detailed 22 | ? date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) 23 | : `${date.getHours()}:00` 24 | } 25 | 26 | if (timeRange === "7d") { 27 | return detailed 28 | ? date.toLocaleDateString([], { 29 | weekday: "short", 30 | month: "short", 31 | day: "numeric", 32 | }) 33 | : date.toLocaleDateString([], { weekday: "short" }) 34 | } 35 | 36 | return detailed 37 | ? date.toLocaleDateString([], { month: "short", day: "numeric" }) 38 | : date.toLocaleDateString([], { month: "short", day: "numeric" }) 39 | } 40 | 41 | export function getIntervalMinutes(timeRange: TimeRange): number { 42 | switch (timeRange) { 43 | case "1h": 44 | return 1 // 1 minute intervals for 1 hour 45 | case "1d": 46 | return 24 // 24 minute intervals for 1 day (60 data points) 47 | case "7d": 48 | return 168 // 168 minute intervals for 7 days (60 data points) 49 | default: 50 | return 1 51 | } 52 | } 53 | 54 | export function getTimeRangeInMinutes(timeRange: TimeRange): number { 55 | switch (timeRange) { 56 | case "1h": 57 | return 60 // 60 minutes 58 | case "1d": 59 | return 1440 // 24 hours * 60 minutes 60 | case "7d": 61 | return 10080 // 7 days * 24 hours * 60 minutes 62 | default: 63 | return 60 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/registry/new-york-v4/hooks/use-mobile.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | const MOBILE_BREAKPOINT = 768 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState(undefined) 7 | 8 | React.useEffect(() => { 9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) 10 | const onChange = () => { 11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 12 | } 13 | mql.addEventListener("change", onChange) 14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 15 | return () => mql.removeEventListener("change", onChange) 16 | }, []) 17 | 18 | return !!isMobile 19 | } 20 | -------------------------------------------------------------------------------- /src/registry/new-york-v4/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/registry/new-york-v4/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 5 | import { ChevronDownIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Accordion({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function AccordionItem({ 16 | className, 17 | ...props 18 | }: React.ComponentProps) { 19 | return ( 20 | 25 | ) 26 | } 27 | 28 | function AccordionTrigger({ 29 | className, 30 | children, 31 | ...props 32 | }: React.ComponentProps) { 33 | return ( 34 | 35 | svg]:rotate-180", 39 | className 40 | )} 41 | {...props} 42 | > 43 | {children} 44 | 45 | 46 | 47 | ) 48 | } 49 | 50 | function AccordionContent({ 51 | className, 52 | children, 53 | ...props 54 | }: React.ComponentProps) { 55 | return ( 56 | 61 |
{children}
62 |
63 | ) 64 | } 65 | 66 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 67 | -------------------------------------------------------------------------------- /src/registry/new-york-v4/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/registry/new-york-v4/ui/button" 8 | 9 | function AlertDialog({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function AlertDialogTrigger({ 16 | ...props 17 | }: React.ComponentProps) { 18 | return ( 19 | 20 | ) 21 | } 22 | 23 | function AlertDialogPortal({ 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 28 | ) 29 | } 30 | 31 | function AlertDialogOverlay({ 32 | className, 33 | ...props 34 | }: React.ComponentProps) { 35 | return ( 36 | 44 | ) 45 | } 46 | 47 | function AlertDialogContent({ 48 | className, 49 | ...props 50 | }: React.ComponentProps) { 51 | return ( 52 | 53 | 54 | 62 | 63 | ) 64 | } 65 | 66 | function AlertDialogHeader({ 67 | className, 68 | ...props 69 | }: React.ComponentProps<"div">) { 70 | return ( 71 |
76 | ) 77 | } 78 | 79 | function AlertDialogFooter({ 80 | className, 81 | ...props 82 | }: React.ComponentProps<"div">) { 83 | return ( 84 |
92 | ) 93 | } 94 | 95 | function AlertDialogTitle({ 96 | className, 97 | ...props 98 | }: React.ComponentProps) { 99 | return ( 100 | 105 | ) 106 | } 107 | 108 | function AlertDialogDescription({ 109 | className, 110 | ...props 111 | }: React.ComponentProps) { 112 | return ( 113 | 118 | ) 119 | } 120 | 121 | function AlertDialogAction({ 122 | className, 123 | ...props 124 | }: React.ComponentProps) { 125 | return ( 126 | 130 | ) 131 | } 132 | 133 | function AlertDialogCancel({ 134 | className, 135 | ...props 136 | }: React.ComponentProps) { 137 | return ( 138 | 142 | ) 143 | } 144 | 145 | export { 146 | AlertDialog, 147 | AlertDialogPortal, 148 | AlertDialogOverlay, 149 | AlertDialogTrigger, 150 | AlertDialogContent, 151 | AlertDialogHeader, 152 | AlertDialogFooter, 153 | AlertDialogTitle, 154 | AlertDialogDescription, 155 | AlertDialogAction, 156 | AlertDialogCancel, 157 | } 158 | -------------------------------------------------------------------------------- /src/registry/new-york-v4/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-card text-card-foreground", 12 | destructive: 13 | "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | function Alert({ 23 | className, 24 | variant, 25 | ...props 26 | }: React.ComponentProps<"div"> & VariantProps) { 27 | return ( 28 |
34 | ) 35 | } 36 | 37 | function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { 38 | return ( 39 |
47 | ) 48 | } 49 | 50 | function AlertDescription({ 51 | className, 52 | ...props 53 | }: React.ComponentProps<"div">) { 54 | return ( 55 |
63 | ) 64 | } 65 | 66 | export { Alert, AlertTitle, AlertDescription } 67 | -------------------------------------------------------------------------------- /src/registry/new-york-v4/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" 4 | 5 | function AspectRatio({ 6 | ...props 7 | }: React.ComponentProps) { 8 | return 9 | } 10 | 11 | export { AspectRatio } 12 | -------------------------------------------------------------------------------- /src/registry/new-york-v4/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Avatar({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | function AvatarImage({ 25 | className, 26 | ...props 27 | }: React.ComponentProps) { 28 | return ( 29 | 34 | ) 35 | } 36 | 37 | function AvatarFallback({ 38 | className, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 50 | ) 51 | } 52 | 53 | export { Avatar, AvatarImage, AvatarFallback } 54 | -------------------------------------------------------------------------------- /src/registry/new-york-v4/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const badgeVariants = cva( 8 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 14 | secondary: 15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 | warning: 17 | "border-transparent bg-amber-500 text-white [a&]:hover:bg-amber-500/90 focus-visible:ring-amber-500/20 dark:focus-visible:ring-amber-500/40 dark:bg-amber-500/60", 18 | destructive: 19 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 20 | outline: 21 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 22 | }, 23 | }, 24 | defaultVariants: { 25 | variant: "default", 26 | }, 27 | } 28 | ) 29 | 30 | function Badge({ 31 | className, 32 | variant, 33 | asChild = false, 34 | ...props 35 | }: React.ComponentProps<"span"> & 36 | VariantProps & { asChild?: boolean }) { 37 | const Comp = asChild ? Slot : "span" 38 | 39 | return ( 40 | 45 | ) 46 | } 47 | 48 | export { Badge, badgeVariants } 49 | -------------------------------------------------------------------------------- /src/registry/new-york-v4/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { ChevronRight, MoreHorizontal } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { 8 | return