diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 0000000..2523798
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,16 @@
+name: Lint
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: oven-sh/setup-bun@v2
+ - run: bun install --frozen-lockfile
+ - run: bun run lint
+ - run: bun run format:check
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100644
index 0000000..ea5a55b
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1 @@
+bunx lint-staged
diff --git a/.oxlintrc.json b/.oxlintrc.json
new file mode 100644
index 0000000..b7f88e5
--- /dev/null
+++ b/.oxlintrc.json
@@ -0,0 +1,42 @@
+{
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
+ "plugins": ["react", "jsx-a11y", "import", "typescript", "oxc"],
+ "env": {
+ "browser": true,
+ "node": true,
+ "es2024": true
+ },
+ "settings": {
+ "react": {
+ "version": "19"
+ }
+ },
+ "categories": {
+ "correctness": "error",
+ "suspicious": "warn"
+ },
+ "rules": {
+ "react/react-in-jsx-scope": "off"
+ },
+ "overrides": [
+ {
+ "files": ["src/client/main.tsx"],
+ "rules": {
+ "import/no-unassigned-import": "off"
+ }
+ },
+ {
+ "files": ["src/client/components/charts/*.tsx"],
+ "rules": {
+ "jsx-a11y/prefer-tag-over-role": "off"
+ }
+ },
+ {
+ "files": ["src/client/components/StatusHistoryBar.tsx"],
+ "rules": {
+ "react/capitalized-calls": "off"
+ }
+ }
+ ],
+ "ignorePatterns": ["dist/**", ".wrangler/**", "worker-configuration.d.ts", "migrations/**", "node_modules/**"]
+}
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..70393fb
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,6 @@
+dist
+.wrangler
+node_modules
+bun.lock
+worker-configuration.d.ts
+migrations
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..41aa4f1
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,3 @@
+{
+ "recommendations": ["esbenp.prettier-vscode", "oxc.oxc-vscode", "EditorConfig.EditorConfig"]
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 0126e59..febd763 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,5 +1,25 @@
{
"files.associations": {
"wrangler.json": "jsonc"
+ },
+ "editor.formatOnSave": true,
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.codeActionsOnSave": {
+ "source.fixAll.oxc": "explicit"
+ },
+ "[typescript]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[typescriptreact]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[json]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[jsonc]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[css]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
}
-}
\ No newline at end of file
+}
diff --git a/DESIGN.md b/DESIGN.md
index c784b1b..64617ef 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -232,6 +232,7 @@ Typography runs **Circular** at weight 500 for display and 400 for body. The dis
The product itself appears as composited UI screenshots on every page: dashboard tables, SQL editors, query builders, log streams. These screenshots are the brand's argument. They sit inside `{rounded.lg}` 12px containers with subtle 1px hairlines, often arranged 2-up or in a floating "stacked panes" composition above the hero band.
**Key Characteristics:**
+
- Single emerald primary (`{colors.primary}` `#3ecf8e`) as the only chromatic event; everything else is monochrome.
- White canvas marketing track with greyscale hierarchy from `{colors.hairline-cool}` to `{colors.ink}`.
- Custom humanist sans display tier at weight 500 with negative letter-spacing of -1.92px to -0.42px.
@@ -245,6 +246,7 @@ The product itself appears as composited UI screenshots on every page: dashboard
> **Source pages:** home (`/`), `/database`, `/partners/integrations`, `/partners/integrations/powersync`, `/solutions/ai-builders`, `/pricing`.
### Brand & Accent
+
- **Emerald** (`{colors.primary}` — `#3ecf8e`): The signature CTA color. Filled-button background, brand wordmark accent, dot indicator.
- **Emerald Deep** (`{colors.primary-deep}` — `#24b47e`): Pressed-state lift of the primary.
- **Emerald Soft** (`{colors.primary-soft}` — `#4ade80`): Lighter emerald used in chart accents and product UI.
@@ -254,6 +256,7 @@ The product itself appears as composited UI screenshots on every page: dashboard
- **Accent Pink / Crimson / Indigo / Tomato**: Reserved for integration logos and rare chart highlights, never as system colors.
### Surface
+
- **Canvas** (`{colors.canvas}` — `#ffffff`): Default page background.
- **Canvas Soft** (`{colors.canvas-soft}` — `#fafafa`): Barely-tinted off-white for alternating section bands.
- **Canvas Night** (`{colors.canvas-night}` — `#1c1c1c`): Deep near-black used in code blocks, dashboard mockups, featured pricing tier.
@@ -263,6 +266,7 @@ The product itself appears as composited UI screenshots on every page: dashboard
- **Hairline Cool** (`{colors.hairline-cool}` — `#ededed`) / **Hairline Cool 2** (`#efefef`) / **Hairline Cool 3** (`#d4d4d4`): The brand's grey ladder for fine chrome work.
### Text
+
- **Ink** (`{colors.ink}` — `#171717`): Default body text. Near-black, never pure.
- **Ink Secondary** (`{colors.ink-secondary}` — `#212121`): Slightly cooler near-black for body emphasis.
- **Ink Mute** (`{colors.ink-mute}` — `#707070`): Secondary text and helper copy.
@@ -283,71 +287,78 @@ Code blocks use **system mono** (`ui-monospace`, with Menlo / Monaco / Consolas
### Hierarchy
-| Token | Size | Weight | Line Height | Letter Spacing | Use |
-|---|---|---|---|---|---|
-| `{typography.display-xxl}` | 64px | 500 | 1.1 | -1.92px | Hero headline |
-| `{typography.display-xl}` | 48px | 500 | 1.1 | -1.44px | Section opener |
-| `{typography.display-lg}` | 36px | 500 | 1.15 | -0.72px | Sub-section / pricing tier |
-| `{typography.display-md}` | 28px | 500 | 1.2 | -0.42px | Card title |
-| `{typography.heading-lg}` | 22px | 500 | 1.2 | 0 | Compact heading |
-| `{typography.heading-md}` | 18px | 500 | 1.4 | 0 | Section sub-heading |
-| `{typography.body-lg}` | 18px | 400 | 1.55 | 0 | Marketing body lead |
-| `{typography.body-md}` | 16px | 400 | 1.5 | 0 | Default UI body |
-| `{typography.button-md}` | 14px | 500 | 1.0 | 0 | Button label |
-| `{typography.caption}` | 13px | 400 | 1.45 | 0 | Helper, footnote |
-| `{typography.micro}` | 12px | 400 | 1.45 | 0 | Pill label, fine print |
-| `{typography.code}` | 14px | 400 | 1.5 | 0 | Code block content |
+| Token | Size | Weight | Line Height | Letter Spacing | Use |
+| -------------------------- | ---- | ------ | ----------- | -------------- | -------------------------- |
+| `{typography.display-xxl}` | 64px | 500 | 1.1 | -1.92px | Hero headline |
+| `{typography.display-xl}` | 48px | 500 | 1.1 | -1.44px | Section opener |
+| `{typography.display-lg}` | 36px | 500 | 1.15 | -0.72px | Sub-section / pricing tier |
+| `{typography.display-md}` | 28px | 500 | 1.2 | -0.42px | Card title |
+| `{typography.heading-lg}` | 22px | 500 | 1.2 | 0 | Compact heading |
+| `{typography.heading-md}` | 18px | 500 | 1.4 | 0 | Section sub-heading |
+| `{typography.body-lg}` | 18px | 400 | 1.55 | 0 | Marketing body lead |
+| `{typography.body-md}` | 16px | 400 | 1.5 | 0 | Default UI body |
+| `{typography.button-md}` | 14px | 500 | 1.0 | 0 | Button label |
+| `{typography.caption}` | 13px | 400 | 1.45 | 0 | Helper, footnote |
+| `{typography.micro}` | 12px | 400 | 1.45 | 0 | Pill label, fine print |
+| `{typography.code}` | 14px | 400 | 1.5 | 0 | Code block content |
### Principles
+
- **Weight 500 across display.** Mid-weight reads as engineered, not decorative.
- **Negative tracking on display.** -1.92px at 64px scaling proportionally down — tightens the rounded humanist letterforms into editorial density.
- **Mono for code.** System mono families (Menlo / Monaco) — no proprietary mono webfont.
### Note on Font Substitutes
+
Circular is proprietary. Use **Inter** at weight 500 with `letter-spacing: -1.92px` for display tiers. **Geist Sans** (open-source from Vercel) is another close alternative for both display and body. Avoid Helvetica defaults — they're heavier and lack the geometric warmth.
## Layout
### Spacing System
+
- **Base unit**: 8px (with 2 / 4 / 12 sub-tokens for fine work).
- **Tokens**: `{spacing.xxs}` 2px · `{spacing.xs}` 4px · `{spacing.sm}` 8px · `{spacing.md}` 12px · `{spacing.lg}` 16px · `{spacing.xl}` 24px · `{spacing.xxl}` 32px · `{spacing.huge}` 64px.
- **Section padding**: 64–96px on marketing surfaces.
- **Card internal padding**: 32px on feature/pricing cards.
### Grid & Container
+
- Marketing pages center in a ~1280px container with no edge-bleed; the brand keeps content inside the box.
- Pricing collapses 4-up → 2-up → 1-up at 1024 / 768 breakpoints.
- Product UI mockups stack 2-up or render as overlapping panes inside the same container.
### Whitespace Philosophy
+
The brand uses generous 64–96px section padding without atmospheric gradients filling the space — the white canvas is the design. The composited product UI mockups break up sections without requiring decoration.
## Elevation & Depth
-| Level | Treatment | Use |
-|---|---|---|
-| 0 | Flat, 1px hairline | Default cards |
-| 1 | `box-shadow: 0 1px 3px rgba(0,0,0,0.06)` | Subtle card lift |
-| 2 | `box-shadow: 0 8px 24px rgba(0,0,0,0.08)` | Floating composited UI mockups |
-| 3 | `box-shadow: 0 16px 48px rgba(0,0,0,0.12)` | Modal overlays, deep elevation |
+| Level | Treatment | Use |
+| ----- | ------------------------------------------ | ------------------------------ |
+| 0 | Flat, 1px hairline | Default cards |
+| 1 | `box-shadow: 0 1px 3px rgba(0,0,0,0.06)` | Subtle card lift |
+| 2 | `box-shadow: 0 8px 24px rgba(0,0,0,0.08)` | Floating composited UI mockups |
+| 3 | `box-shadow: 0 16px 48px rgba(0,0,0,0.12)` | Modal overlays, deep elevation |
### Decorative Depth
+
The brand's depth is **product UI mockups** rather than gradients. Stacked dashboard / SQL editor / log panes composite together with subtle Level 2 shadows to suggest spatial hierarchy.
## Shapes
### Border Radius Scale
-| Token | Value | Use |
-|---|---|---|
-| `{rounded.xs}` | 4px | Form inputs, hairline tags |
-| `{rounded.sm}` | 6px | Buttons (the brand's signature button radius), code blocks |
-| `{rounded.md}` | 8px | Compact cards, alerts |
-| `{rounded.lg}` | 12px | Pricing cards, feature cards, product mockups |
-| `{rounded.xl}` | 16px | Modal dialogs, large container chrome |
-| `{rounded.full}` | 9999px | Pill tags, avatars |
+| Token | Value | Use |
+| ---------------- | ------ | ---------------------------------------------------------- |
+| `{rounded.xs}` | 4px | Form inputs, hairline tags |
+| `{rounded.sm}` | 6px | Buttons (the brand's signature button radius), code blocks |
+| `{rounded.md}` | 8px | Compact cards, alerts |
+| `{rounded.lg}` | 12px | Pricing cards, feature cards, product mockups |
+| `{rounded.xl}` | 16px | Modal dialogs, large container chrome |
+| `{rounded.full}` | 9999px | Pill tags, avatars |
### Photography Geometry
+
The brand uses minimal photography. Customer logo strips display wordmarks at uniform height (~24–32px) in greyscale; case-study cards (rare) use 4:3 photos inset in `{rounded.lg}` containers.
## Components
@@ -355,51 +366,64 @@ The brand uses minimal photography. Customer logo strips display wordmarks at un
### Buttons
**`button-primary-green`** — the signature CTA.
+
- Background `{colors.primary}`, text `{colors.on-primary}` (near-black, NOT white), type `{typography.button-md}`, padding `{spacing.sm} {spacing.lg}` (8px 16px), rounded `{rounded.sm}` 6px.
- Pressed state `button-primary-green-pressed` shifts to `{colors.primary-deep}`.
**`button-secondary-outline`** — outline alternative on white.
+
- Background `{colors.canvas}`, text `{colors.ink}`, 1px solid `{colors.hairline-strong}` border, same shape.
**`button-on-dark`** — used on dark surfaces / code-block CTAs.
+
- Background `{colors.canvas-night}`, text `{colors.on-dark}`, same shape.
**`button-link`** — text-only inline button.
+
- Transparent background, text `{colors.ink}` rendered in `{typography.button-md}`, no padding, with a subtle underline on hover.
### Cards & Containers
**`card-feature-light`** — feature card on white.
+
- Background `{colors.canvas}`, padding `{spacing.xxl}`, rounded `{rounded.lg}` 12px, 1px `{colors.hairline}` border.
**`card-pricing`** — standard pricing tier.
+
- Background `{colors.canvas}`, padding `{spacing.xxl}`, rounded `{rounded.lg}`, 1px `{colors.hairline}` border. Title in `{typography.heading-lg}`, price in `{typography.display-md}`, body in `{typography.body-md}`, CTA `button-primary-green` pinned bottom.
**`card-pricing-featured`** — inverted dark featured tier.
+
- Background `{colors.canvas-night}`, text `{colors.on-dark}`, otherwise identical structure.
**`card-feature-dark`** — feature card with deep dark fill.
+
- Background `{colors.canvas-night}`, text `{colors.on-dark}`, padding `{spacing.xxl}`, rounded `{rounded.lg}`. Used for code-heavy feature explanations.
**`code-block`** — code snippet container.
+
- Background `{colors.canvas-night}`, text `{colors.on-dark}` rendered in `{typography.code}`. Padding `{spacing.lg}` 16px, rounded `{rounded.sm}` 6px.
### Inputs & Forms
**`text-input`** — standard form input.
+
- Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body-md}`, padding `{spacing.sm} {spacing.md}` (8px 12px), rounded `{rounded.sm}` 6px, 1px `{colors.hairline}` border.
### Navigation
**`nav-bar-light`** — top nav across the site.
+
- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.lg} {spacing.xl}`. Logo on the left, primary nav center, "Sign In" link + filled `button-primary-green` on the right.
### Pills, Tags, and Chips
**`pill-tag-green`** — small green pill used for "new" or featured indicators.
+
- Background `{colors.primary}`, text `{colors.on-primary}`, type `{typography.micro}`, padding `{spacing.xxs} {spacing.sm}`, rounded `{rounded.full}`.
**`pill-tag-soft`** — neutral pill on light surfaces.
+
- Background `{colors.canvas-soft}`, text `{colors.ink}`, otherwise same shape.
### Signature Components
@@ -407,14 +431,17 @@ The brand uses minimal photography. Customer logo strips display wordmarks at un
**Composited Product UI Mockups** — multi-layer dashboard / SQL editor / log pane composites with subtle Level 2 shadows. The product is the brand's argument; mockups always sit on white canvas with no surrounding decoration.
**`link-on-light`** — inline links in body copy.
+
- Text `{colors.ink}` rendered in `{typography.body-md}` with a persistent underline.
**`footer-light`** — site-wide footer.
+
- Background `{colors.canvas}`, text `{colors.ink-mute}`, type `{typography.caption}`, padding `{spacing.huge} {spacing.xl}` (64px 24px). Holds 4–5 columns of link groups, social icons, and a small legal row.
## Do's and Don'ts
### Do
+
- Reserve `{colors.primary}` emerald for filled CTAs and the wordmark accent — it should appear sparingly.
- Render display tiers at weight 500 with negative letter-spacing — the engineered tightness is part of the brand.
- Use `{rounded.sm}` 6px for buttons — square-ish radii, never pill-shaped.
@@ -423,6 +450,7 @@ The brand uses minimal photography. Customer logo strips display wordmarks at un
- Apply system mono for every code block.
### Don't
+
- Don't introduce additional accent colors as system colors — purples, yellows, and pinks belong inside chart points and integration logos only.
- Don't bump display weight above 500 — the brand's calibrated mid-weight breaks at 600+.
- Don't use pill-shaped buttons; the brand's button radius is square-ish 6px.
@@ -433,23 +461,26 @@ The brand uses minimal photography. Customer logo strips display wordmarks at un
### Breakpoints
-| Name | Width | Key Changes |
-|---|---|---|
-| Wide | ≥ 1440px | Full container width; product mockups at full scale |
-| Desktop | 1024–1440px | Default content max-width; pricing 4-up |
-| Tablet | 768–1023px | Pricing 2-up; mockups simplify to single panel |
-| Mobile | < 768px | Pricing 1-up; hamburger nav; display drops 64 → 36px |
+| Name | Width | Key Changes |
+| ------- | ----------- | ---------------------------------------------------- |
+| Wide | ≥ 1440px | Full container width; product mockups at full scale |
+| Desktop | 1024–1440px | Default content max-width; pricing 4-up |
+| Tablet | 768–1023px | Pricing 2-up; mockups simplify to single panel |
+| Mobile | < 768px | Pricing 1-up; hamburger nav; display drops 64 → 36px |
### Touch Targets
+
- Buttons hit ≥ 36×36px on mobile; vertical padding scales up to maintain WCAG AA minimum.
- Form fields stay at 36px minimum height.
### Collapsing Strategy
+
- Display tiers stair-step 64 → 48 → 36 → 28 → 22px.
- Product UI mockups simplify to a single primary panel on mobile.
- Pricing tiers stair-step 4-up → 2-up → 1-up; dark featured tier always distinguished.
### Image Behavior
+
Product UI mockups use `srcset` with desktop / mobile crops; mobile crops focus on the most actionable inner panel.
## Iteration Guide
diff --git a/bun.lock b/bun.lock
index c0ea580..5bc3623 100644
--- a/bun.lock
+++ b/bun.lock
@@ -22,6 +22,10 @@
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.0",
"drizzle-kit": "^0.31.10",
+ "husky": "^9.1.7",
+ "lint-staged": "^17.4.1",
+ "oxlint": "^1.80.0",
+ "prettier": "^3.9.6",
"tailwindcss": "4",
"typescript": "^5.5.2",
"vite": "^8.2.2",
@@ -181,6 +185,44 @@
"@oxc-project/types": ["@oxc-project/types@0.147.0", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="],
+ "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.80.0", "", { "os": "android", "cpu": "arm" }, "sha512-RM3Plj+biQpxa5d1GOOX6ciDlcUROmm4OZ/pLTpitkQt2mJv4jhtY4cbgaetOm5UKWZe05/TGQ6o1Vl8EOHkrA=="],
+
+ "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.80.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YlO5JEf0Yr2bUUlu8O8daVcUxtcGGbcSmyV7E7nSbJbfAdxTE0PFPwgnIlw7wXJaTYjb+qs5hI5q3jxUkI7cAw=="],
+
+ "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.80.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BULDOyO3AhsmdWfQeIUCykDt3dd7XZBGLhp1eIh56skRv01O+cNjNPwXMIbeW1x4+pxcln5if72wcRgViVo7PA=="],
+
+ "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.80.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-YJ4JzLw7N5TDSQFlA0hAQGHvnDZgyypm1yunObVWcWiF9KM7eGCJKYKLgTC2Fi/57OdnBhbj4OkzPGdFQJ6HyA=="],
+
+ "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.80.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-AYUIk5QnL0s8oWAYsREZwkRYy1SupJTXALo93J1TgzHywxQtdM99FecRMQ87MXEdPQ0j1TmEpeeq3fGNkpvMqg=="],
+
+ "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.80.0", "", { "os": "linux", "cpu": "arm" }, "sha512-9hBZVANupQ89W9dXyE0n8doCyaW5pDyGn3y6XlIMPZ+rIKuyqkr3SNUXmVJIhuvUq0NBU3RBiSXXE69l4XI6KA=="],
+
+ "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.80.0", "", { "os": "linux", "cpu": "arm" }, "sha512-SvS2uKqzY+pbfuvAHzH4338R6Zwo805GAwrIMVvK1KxoOWCIjZUdfzTCvilD7z6JK91v011+zYMryabhDo2AsQ=="],
+
+ "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.80.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tCLadyqRVL3pQTRPNg7cjXKvcvS4fbyXeQHhKk5BTJ1oftQln5/yIIWbu/Xom/DX41zv2P9QGt6+D/TtQVtY3A=="],
+
+ "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.80.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-XfpCNRlOPcLlJl4Bn/FUhjqlR6BVavEykERBf/MV7YA9VZDa5g5znVqYhyviMafcxS9Pe/i/kPvHNO0U6svEHQ=="],
+
+ "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.80.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3I4yMwcFG9NeO8ioY6JBBuKsIm5GL/x7MATt1S4tVWaxPu5HcJ+XnLUbcVBTxG8q2Wu56HSj+NmXQiVYb1lp6A=="],
+
+ "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.80.0", "", { "os": "linux", "cpu": "none" }, "sha512-E1wAKymkpe1/E8helzBKdm81OBOF+ezxRyXRMEuik3ZpWDER5CPOKZwF66RsdwW98uwZv8UTFremUQtC1CzdJA=="],
+
+ "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.80.0", "", { "os": "linux", "cpu": "none" }, "sha512-+gLRGD4sIo3+VA++iham5UxD9tKSoJ/VOrROCEXIcknrYtQg6iIQgvjN0cpiRF7N6UYC7pJbvHJlDnMge5LRpQ=="],
+
+ "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.80.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-aR0PrzHj9leW3NmzBAAP4EzdoBNoJcs9sjnIQPIwyRnBGYrRbXUIpEB5Q39AqK3PLY5JK5uEhDQDiUa1QSAstw=="],
+
+ "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.80.0", "", { "os": "linux", "cpu": "x64" }, "sha512-vSVh5cSo3Xxs6ghBCcFJlpbkbENzDog1qXtoXLa/HC3aCrR4XO76GZbXmQoCPHnu99nQpdCeC3H9tdNICfDh7A=="],
+
+ "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.80.0", "", { "os": "linux", "cpu": "x64" }, "sha512-FfzBXpNQ8u7/ZI/p8bl73MeZ508Ax3hxWp3SiJpEFiC+BB9XcXy5FAZHTLKDPSzrUpxQZSZJAVdDmuJp/+HDBQ=="],
+
+ "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.80.0", "", { "os": "none", "cpu": "arm64" }, "sha512-zMzbkumtmprCgRwoYNzcB3iC39fXdJIMLMU33KdCjEGLlJGOEt1+LwQ4LF8ndLzAEKVz4BR0y3V6Xrkk3Nm3yA=="],
+
+ "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.80.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ib6iRcrXsk4t1fm3iKcwksyWh1ZkZXC/2mEzakl0ai2+6HZunf1WWMZ/xP9EJAvw9g9K4UVTC3NF/+G2qLrbTQ=="],
+
+ "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.80.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-xhRWBMpLxZvgKAH6+DJZmpP+W8Y8UdQOSU1JfxSWNXsaBaRGW77j+1hCuNHlzj7OH4SPN8fYd1q0o2qrDtoVyw=="],
+
+ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.80.0", "", { "os": "win32", "cpu": "x64" }, "sha512-yAnO7lwBYQnz2pcfBPIGQQZWIX5zd5R/1aAKIF3oE+TVj7IhoHcROjOkz3sRDngzqhfPKfFaXqug5j5rE5dn6Q=="],
+
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
@@ -385,6 +427,8 @@
"hono": ["hono@4.13.5", "", {}, "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw=="],
+ "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
+
"immer": ["immer@11.1.18", "", {}, "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
@@ -417,6 +461,8 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
+ "lint-staged": ["lint-staged@17.4.1", "", { "dependencies": { "picomatch": "^4.0.7", "string-argv": "^0.3.2", "tinyexec": "^1.3.0" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-FmJeudcalbSfg1du+JCfvi5vS6Qt08KgbfLWiHinbef+2JJwUZwAWVoaO1AcJVUTWPfk0t30PMQNwPAeCzYQ+Q=="],
+
"lucide-react": ["lucide-react@1.34.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
@@ -427,6 +473,8 @@
"obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
+ "oxlint": ["oxlint@1.80.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.80.0", "@oxlint/binding-android-arm64": "1.80.0", "@oxlint/binding-darwin-arm64": "1.80.0", "@oxlint/binding-darwin-x64": "1.80.0", "@oxlint/binding-freebsd-x64": "1.80.0", "@oxlint/binding-linux-arm-gnueabihf": "1.80.0", "@oxlint/binding-linux-arm-musleabihf": "1.80.0", "@oxlint/binding-linux-arm64-gnu": "1.80.0", "@oxlint/binding-linux-arm64-musl": "1.80.0", "@oxlint/binding-linux-ppc64-gnu": "1.80.0", "@oxlint/binding-linux-riscv64-gnu": "1.80.0", "@oxlint/binding-linux-riscv64-musl": "1.80.0", "@oxlint/binding-linux-s390x-gnu": "1.80.0", "@oxlint/binding-linux-x64-gnu": "1.80.0", "@oxlint/binding-linux-x64-musl": "1.80.0", "@oxlint/binding-openharmony-arm64": "1.80.0", "@oxlint/binding-win32-arm64-msvc": "1.80.0", "@oxlint/binding-win32-ia32-msvc": "1.80.0", "@oxlint/binding-win32-x64-msvc": "1.80.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA=="],
+
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
@@ -437,6 +485,8 @@
"postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
+ "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="],
+
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
@@ -475,6 +525,8 @@
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
+ "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
+
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
@@ -519,6 +571,8 @@
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
+ "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
+
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
diff --git a/drizzle.config.ts b/drizzle.config.ts
index f83ab8b..cdffd2c 100644
--- a/drizzle.config.ts
+++ b/drizzle.config.ts
@@ -1,7 +1,7 @@
-import { defineConfig } from "drizzle-kit";
+import { defineConfig } from 'drizzle-kit';
export default defineConfig({
- dialect: "sqlite",
- schema: "./src/worker/db/schema.ts",
- out: "./migrations",
+ dialect: 'sqlite',
+ schema: './src/worker/db/schema.ts',
+ out: './migrations',
});
diff --git a/index.html b/index.html
index dea5739..dbbd881 100644
--- a/index.html
+++ b/index.html
@@ -3,10 +3,7 @@
-
+
Upwatch — Uptime monitoring
diff --git a/package.json b/package.json
index 630fcf5..a64dd3a 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,12 @@
"types": "wrangler types",
"db:local": "wrangler d1 migrations apply uptime --local",
"db:remote": "wrangler d1 migrations apply uptime --remote",
- "admin:create": "bun scripts/create-admin.ts"
+ "admin:create": "bun scripts/create-admin.ts",
+ "lint": "oxlint",
+ "lint:fix": "oxlint --fix",
+ "format": "prettier --write .",
+ "format:check": "prettier --check .",
+ "prepare": "husky"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.54.1",
@@ -22,6 +27,10 @@
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.0",
"drizzle-kit": "^0.31.10",
+ "husky": "^9.1.7",
+ "lint-staged": "^17.4.1",
+ "oxlint": "^1.80.0",
+ "prettier": "^3.9.6",
"tailwindcss": "4",
"typescript": "^5.5.2",
"vite": "^8.2.2",
@@ -40,5 +49,14 @@
"trustedDependencies": [
"workerd",
"esbuild"
- ]
+ ],
+ "lint-staged": {
+ "*.{ts,tsx}": [
+ "oxlint --fix",
+ "prettier --write"
+ ],
+ "*.{js,jsx,json,jsonc,css,md}": [
+ "prettier --write"
+ ]
+ }
}
diff --git a/scripts/create-admin.ts b/scripts/create-admin.ts
index 0508cb7..1369fdf 100644
--- a/scripts/create-admin.ts
+++ b/scripts/create-admin.ts
@@ -1,35 +1,35 @@
-import { hashPassword } from "../src/worker/lib/password";
+import { hashPassword } from '../src/worker/lib/password';
async function readPassword() {
if (!process.stdin.isTTY) {
return (await new Response(Bun.stdin.stream()).text()).trimEnd();
}
- process.stdout.write("Password: ");
+ process.stdout.write('Password: ');
process.stdin.setRawMode(true);
process.stdin.resume();
- process.stdin.setEncoding("utf8");
+ process.stdin.setEncoding('utf8');
return new Promise((resolve, reject) => {
- let password = "";
+ let password = '';
const cleanup = () => {
process.stdin.setRawMode(false);
process.stdin.pause();
- process.stdout.write("\n");
+ process.stdout.write('\n');
};
- process.stdin.on("data", (key: string) => {
- if (key === "\u0003") {
+ process.stdin.on('data', (key: string) => {
+ if (key === '\u0003') {
cleanup();
- reject(new Error("Cancelled"));
+ reject(new Error('Cancelled'));
return;
}
- if (key === "\r" || key === "\n") {
+ if (key === '\r' || key === '\n') {
cleanup();
resolve(password);
return;
}
- if (key === "\u007f" || key === "\b") {
+ if (key === '\u007f' || key === '\b') {
password = password.slice(0, -1);
return;
}
@@ -43,23 +43,23 @@ function sqlValue(value: string) {
}
function shellDoubleQuoted(value: string) {
- return value.replace(/[\\"$`]/g, "\\$&");
+ return value.replace(/[\\"$`]/g, '\\$&');
}
const password = await readPassword();
if (password.length < 8) {
- console.error("Password must contain at least 8 characters.");
+ console.error('Password must contain at least 8 characters.');
process.exit(1);
}
const now = Date.now();
const statement = [
- "INSERT OR REPLACE INTO admin_credentials",
- "(id, password_hash, created_at, updated_at)",
+ 'INSERT OR REPLACE INTO admin_credentials',
+ '(id, password_hash, created_at, updated_at)',
`VALUES (1, ${sqlValue(await hashPassword(password))}, ${now}, ${now});`,
-].join(" ");
+].join(' ');
-console.log("\nLocal database:");
+console.log('\nLocal database:');
console.log(`bunx wrangler d1 execute uptime --local --command "${shellDoubleQuoted(statement)}"`);
-console.log("\nRemote database:");
+console.log('\nRemote database:');
console.log(`bunx wrangler d1 execute uptime --remote --command "${shellDoubleQuoted(statement)}"`);
diff --git a/src/client/App.tsx b/src/client/App.tsx
index 8407f78..fbfe00b 100644
--- a/src/client/App.tsx
+++ b/src/client/App.tsx
@@ -1,26 +1,24 @@
-import { RequireAuth } from "./components/RequireAuth";
-import { usePathname } from "./lib/router";
-import { DashboardPage } from "./pages/DashboardPage";
-import { LoginPage } from "./pages/LoginPage";
-import { MonitorDetailPage } from "./pages/MonitorDetailPage";
-import { SettingsPage } from "./pages/SettingsPage";
-import { StatusPage } from "./pages/StatusPage";
+import { RequireAuth } from './components/RequireAuth';
+import { usePathname } from './lib/router';
+import { DashboardPage } from './pages/DashboardPage';
+import { LoginPage } from './pages/LoginPage';
+import { MonitorDetailPage } from './pages/MonitorDetailPage';
+import { SettingsPage } from './pages/SettingsPage';
+import { StatusPage } from './pages/StatusPage';
function App() {
const pathname = usePathname();
- if (pathname === "/") return ;
- if (pathname === "/login") return ;
+ if (pathname === '/') return ;
+ if (pathname === '/login') return ;
const monitorMatch = pathname.match(/^\/monitors\/(\d+)\/?$/);
- const page = monitorMatch
- ?
- : pathname === "/settings"
- ?
- : ;
- return (
-
- {page}
-
+ const page = monitorMatch ? (
+
+ ) : pathname === '/settings' ? (
+
+ ) : (
+
);
+ return {page} ;
}
export default App;
diff --git a/src/client/api/auth.ts b/src/client/api/auth.ts
index 152b63a..f5a1592 100644
--- a/src/client/api/auth.ts
+++ b/src/client/api/auth.ts
@@ -1,4 +1,4 @@
-import { getJson, postJson } from "./http";
+import { getJson, postJson } from './http';
export type SessionResponse = {
authenticated: boolean;
@@ -9,16 +9,16 @@ export type LoginInput = {
};
export function getSession(signal?: AbortSignal) {
- return getJson("/api/auth/me", {
+ return getJson('/api/auth/me', {
signal,
- credentials: "same-origin",
+ credentials: 'same-origin',
});
}
export function login(input: LoginInput) {
- return postJson("/api/auth/login", input);
+ return postJson('/api/auth/login', input);
}
export function logout() {
- return postJson<{ ok: true }>("/api/auth/logout");
+ return postJson<{ ok: true }>('/api/auth/logout');
}
diff --git a/src/client/api/health.ts b/src/client/api/health.ts
index 053a085..3d5be9e 100644
--- a/src/client/api/health.ts
+++ b/src/client/api/health.ts
@@ -1,4 +1,4 @@
-import { getJson } from "./http";
+import { getJson } from './http';
export type HealthResponse = {
ok: boolean;
@@ -7,5 +7,5 @@ export type HealthResponse = {
};
export function getHealth(signal?: AbortSignal) {
- return getJson("/api/health", { signal });
+ return getJson('/api/health', { signal });
}
diff --git a/src/client/api/http.ts b/src/client/api/http.ts
index 7893ebe..611487e 100644
--- a/src/client/api/http.ts
+++ b/src/client/api/http.ts
@@ -4,14 +4,14 @@ export class ApiError extends Error {
readonly status: number,
) {
super(message);
- this.name = "ApiError";
+ this.name = 'ApiError';
}
}
async function getErrorMessage(response: Response) {
try {
const body = await response.json<{ message?: unknown }>();
- if (typeof body.message === "string" && body.message.length > 0) {
+ if (typeof body.message === 'string' && body.message.length > 0) {
return body.message;
}
} catch {
@@ -21,12 +21,9 @@ async function getErrorMessage(response: Response) {
return `Request returned HTTP ${response.status}`;
}
-export async function getJson(
- input: RequestInfo | URL,
- init: RequestInit = {},
-): Promise {
+export async function getJson(input: RequestInfo | URL, init: RequestInit = {}): Promise {
const headers = new Headers(init.headers);
- headers.set("Accept", "application/json");
+ headers.set('Accept', 'application/json');
const response = await fetch(input, {
...init,
@@ -40,51 +37,31 @@ export async function getJson(
return response.json() as Promise;
}
-function sendJson(
- method: "POST" | "PUT" | "PATCH" | "DELETE",
- input: RequestInfo | URL,
- body?: unknown,
- init: RequestInit = {},
-) {
+function sendJson(method: 'POST' | 'PUT' | 'PATCH' | 'DELETE', input: RequestInfo | URL, body?: unknown, init: RequestInit = {}) {
const headers = new Headers(init.headers);
- headers.set("Content-Type", "application/json");
+ headers.set('Content-Type', 'application/json');
return getJson(input, {
...init,
method,
headers,
- credentials: "same-origin",
+ credentials: 'same-origin',
body: body === undefined ? undefined : JSON.stringify(body),
});
}
-export function postJson(
- input: RequestInfo | URL,
- body?: unknown,
- init: RequestInit = {},
-) {
- return sendJson("POST", input, body, init);
+export function postJson(input: RequestInfo | URL, body?: unknown, init: RequestInit = {}) {
+ return sendJson('POST', input, body, init);
}
-export function patchJson(
- input: RequestInfo | URL,
- body?: unknown,
- init: RequestInit = {},
-) {
- return sendJson("PATCH", input, body, init);
+export function patchJson(input: RequestInfo | URL, body?: unknown, init: RequestInit = {}) {
+ return sendJson('PATCH', input, body, init);
}
-export function putJson(
- input: RequestInfo | URL,
- body?: unknown,
- init: RequestInit = {},
-) {
- return sendJson("PUT", input, body, init);
+export function putJson(input: RequestInfo | URL, body?: unknown, init: RequestInit = {}) {
+ return sendJson('PUT', input, body, init);
}
-export function deleteJson(
- input: RequestInfo | URL,
- init: RequestInit = {},
-) {
- return sendJson("DELETE", input, undefined, init);
+export function deleteJson(input: RequestInfo | URL, init: RequestInit = {}) {
+ return sendJson('DELETE', input, undefined, init);
}
diff --git a/src/client/api/monitors.ts b/src/client/api/monitors.ts
index 9937244..ff2c527 100644
--- a/src/client/api/monitors.ts
+++ b/src/client/api/monitors.ts
@@ -1,6 +1,6 @@
-import { deleteJson, getJson, patchJson, postJson } from "./http";
+import { deleteJson, getJson, patchJson, postJson } from './http';
-export type MonitorMethod = "GET" | "HEAD" | "POST";
+export type MonitorMethod = 'GET' | 'HEAD' | 'POST';
export type Monitor = {
id: number;
@@ -66,34 +66,34 @@ export type StatsWindow = {
};
export type MonitorStats = {
- windows: Record<"24h" | "7d" | "30d" | "90d", StatsWindow>;
+ windows: Record<'24h' | '7d' | '30d' | '90d', StatsWindow>;
};
export function listMonitors(signal?: AbortSignal) {
- return getJson<{ monitors: Monitor[] }>("/api/monitors", {
+ return getJson<{ monitors: Monitor[] }>('/api/monitors', {
signal,
- credentials: "same-origin",
+ credentials: 'same-origin',
});
}
export function getMonitor(id: number, signal?: AbortSignal) {
- return getJson<{ monitor: Monitor }>(`/api/monitors/${id}`, { signal, credentials: "same-origin" });
+ return getJson<{ monitor: Monitor }>(`/api/monitors/${id}`, { signal, credentials: 'same-origin' });
}
export function listChecks(id: number, limit = 100, signal?: AbortSignal) {
- return getJson<{ checks: Check[] }>(`/api/monitors/${id}/checks?limit=${limit}`, { signal, credentials: "same-origin" });
+ return getJson<{ checks: Check[] }>(`/api/monitors/${id}/checks?limit=${limit}`, { signal, credentials: 'same-origin' });
}
export function getMonitorStats(id: number, signal?: AbortSignal) {
- return getJson(`/api/monitors/${id}/stats`, { signal, credentials: "same-origin" });
+ return getJson(`/api/monitors/${id}/stats`, { signal, credentials: 'same-origin' });
}
export function listIncidents(id: number, limit = 50, signal?: AbortSignal) {
- return getJson<{ incidents: Incident[] }>(`/api/monitors/${id}/incidents?limit=${limit}`, { signal, credentials: "same-origin" });
+ return getJson<{ incidents: Incident[] }>(`/api/monitors/${id}/incidents?limit=${limit}`, { signal, credentials: 'same-origin' });
}
export function createMonitor(input: MonitorInput) {
- return postJson<{ monitor: Monitor }>("/api/monitors", input);
+ return postJson<{ monitor: Monitor }>('/api/monitors', input);
}
export function updateMonitor(id: number, input: Partial) {
diff --git a/src/client/api/settings.ts b/src/client/api/settings.ts
index a5f2b04..3662ffc 100644
--- a/src/client/api/settings.ts
+++ b/src/client/api/settings.ts
@@ -1,4 +1,4 @@
-import { getJson, postJson, putJson } from "./http";
+import { getJson, postJson, putJson } from './http';
export type NotificationSettings = {
id: number;
@@ -8,19 +8,19 @@ export type NotificationSettings = {
updatedAt: string | null;
};
-export type NotificationSettingsInput = Pick;
+export type NotificationSettingsInput = Pick;
export function getNotificationSettings(signal?: AbortSignal) {
- return getJson<{ settings: NotificationSettings }>("/api/settings/notifications", {
+ return getJson<{ settings: NotificationSettings }>('/api/settings/notifications', {
signal,
- credentials: "same-origin",
+ credentials: 'same-origin',
});
}
export function updateNotificationSettings(input: NotificationSettingsInput) {
- return putJson<{ settings: NotificationSettings }>("/api/settings/notifications", input);
+ return putJson<{ settings: NotificationSettings }>('/api/settings/notifications', input);
}
export function testNotificationWebhook() {
- return postJson<{ ok: true }>("/api/settings/notifications/test");
+ return postJson<{ ok: true }>('/api/settings/notifications/test');
}
diff --git a/src/client/api/status.ts b/src/client/api/status.ts
index f7bea93..fdf9ca0 100644
--- a/src/client/api/status.ts
+++ b/src/client/api/status.ts
@@ -1,7 +1,7 @@
-import { getJson } from "./http";
+import { getJson } from './http';
-export type PublicServiceStatus = "up" | "down" | "unknown";
-export type PublicOverallStatus = "operational" | "degraded" | "down";
+export type PublicServiceStatus = 'up' | 'down' | 'unknown';
+export type PublicOverallStatus = 'operational' | 'degraded' | 'down';
export type PublicService = {
id: number;
@@ -22,5 +22,5 @@ export type PublicStatus = {
};
export function getStatus(signal?: AbortSignal) {
- return getJson("/api/status", { signal });
+ return getJson('/api/status', { signal });
}
diff --git a/src/client/components/RequireAuth.tsx b/src/client/components/RequireAuth.tsx
index 4ef6f8e..29ccba4 100644
--- a/src/client/components/RequireAuth.tsx
+++ b/src/client/components/RequireAuth.tsx
@@ -1,7 +1,7 @@
-import { type ReactNode, useEffect } from "react";
-import { Zap } from "lucide-react";
-import { navigate } from "../lib/router";
-import { useSessionQuery } from "../queries/auth";
+import { type ReactNode, useEffect } from 'react';
+import { Zap } from 'lucide-react';
+import { navigate } from '../lib/router';
+import { useSessionQuery } from '../queries/auth';
function FullPageLoading() {
return (
@@ -18,7 +18,7 @@ export function RequireAuth({ children }: { children: ReactNode }) {
useEffect(() => {
if (!sessionQuery.isPending && !authenticated) {
- navigate("/login", { replace: true });
+ navigate('/login', { replace: true });
}
}, [sessionQuery.isPending, authenticated]);
diff --git a/src/client/components/SiteIcon.tsx b/src/client/components/SiteIcon.tsx
index 8b3ec3b..e0518c5 100644
--- a/src/client/components/SiteIcon.tsx
+++ b/src/client/components/SiteIcon.tsx
@@ -1,21 +1,19 @@
-import { Database } from "lucide-react";
-import { useState } from "react";
+import { Database } from 'lucide-react';
+import { useState } from 'react';
type SiteIconProps = {
monitorId: number;
- favicon?: "admin" | "public";
+ favicon?: 'admin' | 'public';
};
-export function SiteIcon({ monitorId, favicon = "admin" }: SiteIconProps) {
+export function SiteIcon({ monitorId, favicon = 'admin' }: SiteIconProps) {
const [failed, setFailed] = useState(false);
if (failed) return ;
return (
[entry.day, entry.uptimePct]));
const now = new Date();
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
- const segments = Array.from({ length: HISTORY_SEGMENTS }, (_, index) => {
+ const segments = Array.from({ length: HISTORY_SEGMENTS }, (_segment, index) => {
const startDay = today - (HISTORY_DAYS - index * DAYS_PER_SEGMENT - 1) * DAY_MS;
const endDay = startDay + (DAYS_PER_SEGMENT - 1) * DAY_MS;
- const values = Array.from({ length: DAYS_PER_SEGMENT }, (_, dayIndex) =>
- historyByDay.get(startDay + dayIndex * DAY_MS),
- ).filter((value): value is number => value !== undefined && value !== null);
- const uptimePct = values.length === 0
- ? null
- : Math.round((values.reduce((sum, value) => sum + value, 0) / values.length) * 10) / 10;
+ const values = Array.from({ length: DAYS_PER_SEGMENT }, (_day, dayIndex) => historyByDay.get(startDay + dayIndex * DAY_MS)).filter(
+ (value): value is number => value !== undefined && value !== null,
+ );
+ const uptimePct = values.length === 0 ? null : Math.round((values.reduce((sum, value) => sum + value, 0) / values.length) * 10) / 10;
return { startDay, endDay, uptimePct };
});
@@ -48,11 +46,7 @@ export function StatusHistoryBar({ history }: { history: HistoryEntry[] }) {
{segments.map(({ startDay, endDay, uptimePct }) => (
-
+
))}
diff --git a/src/client/components/charts/LatencySparkline.tsx b/src/client/components/charts/LatencySparkline.tsx
index 73c8544..303850e 100644
--- a/src/client/components/charts/LatencySparkline.tsx
+++ b/src/client/components/charts/LatencySparkline.tsx
@@ -1,15 +1,6 @@
-import { useEffect, useRef } from "react";
-import {
- Area,
- AreaChart,
- CartesianGrid,
- ResponsiveContainer,
- Tooltip,
- XAxis,
- YAxis,
- type TooltipContentProps,
-} from "recharts";
-import type { Check } from "../../api/monitors";
+import { useState } from 'react';
+import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, type TooltipContentProps } from 'recharts';
+import type { Check } from '../../api/monitors';
type LatencyDatum = {
t: string;
@@ -25,13 +16,13 @@ function LatencyTooltip({ active, payload }: TooltipContentProps) {
{new Date(point.t).toLocaleString()}
{point.latency} ms
- {point.ok ? "Up" : "Down"}
+ {point.ok ? 'Up' : 'Down'}
);
}
export function LatencySparkline({ checks }: { checks: Check[] }) {
- const hasAnimated = useRef(false);
+ const [hasAnimated, setHasAnimated] = useState(false);
const data: LatencyDatum[] = checks.toReversed().map((check) => ({
t: check.checkedAt,
latency: check.latencyMs,
@@ -39,21 +30,13 @@ export function LatencySparkline({ checks }: { checks: Check[] }) {
}));
const values = data.map((point) => point.latency);
- useEffect(() => {
- if (data.length >= 2) hasAnimated.current = true;
- }, [data.length]);
-
if (data.length < 2) return
More checks are needed to draw latency.
;
const minimum = Math.min(...values);
const maximum = Math.max(...values);
return (
-
+
@@ -64,26 +47,26 @@ export function LatencySparkline({ checks }: { checks: Check[] }) {
-
-
+
+
setHasAnimated(true)}
/>
-
{maximum} ms {minimum} ms
+
+ {maximum} ms
+ {minimum} ms
+
);
}
diff --git a/src/client/components/charts/UptimeBar.tsx b/src/client/components/charts/UptimeBar.tsx
index 5453041..39501a2 100644
--- a/src/client/components/charts/UptimeBar.tsx
+++ b/src/client/components/charts/UptimeBar.tsx
@@ -1,15 +1,6 @@
-import { useEffect, useRef } from "react";
-import {
- Bar,
- BarChart,
- Cell,
- ResponsiveContainer,
- Tooltip,
- XAxis,
- YAxis,
- type TooltipContentProps,
-} from "recharts";
-import type { Check } from "../../api/monitors";
+import { useState } from 'react';
+import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis, type TooltipContentProps } from 'recharts';
+import type { Check } from '../../api/monitors';
type UptimeDatum = {
t: string;
@@ -25,13 +16,13 @@ function UptimeTooltip({ active, payload }: TooltipContentProps) {
return (
{new Date(point.t).toLocaleString()}
- {point.ok ? "Up" : "Down"}
+ {point.ok ? 'Up' : 'Down'}
);
}
export function UptimeBar({ checks }: { checks: Check[] }) {
- const hasAnimated = useRef(false);
+ const [hasAnimated, setHasAnimated] = useState(false);
const data: UptimeDatum[] = checks.toReversed().map((check) => ({
t: check.checkedAt,
v: 1,
@@ -39,44 +30,39 @@ export function UptimeBar({ checks }: { checks: Check[] }) {
id: check.id,
}));
- useEffect(() => {
- if (data.length > 0) hasAnimated.current = true;
- }, [data.length]);
-
if (data.length === 0) return
No availability checks recorded yet.
;
const successfulChecks = data.filter((check) => check.ok).length;
return (
-
+
-
+
setHasAnimated(true)}
>
{data.map((point) => (
- |
+ |
))}
-
Oldest Up DownLatest
+
+ Oldest
+
+ Up Down
+
+ Latest
+
);
}
diff --git a/src/client/lib/query-client.ts b/src/client/lib/query-client.ts
index 180783d..d56b81b 100644
--- a/src/client/lib/query-client.ts
+++ b/src/client/lib/query-client.ts
@@ -1,8 +1,8 @@
-import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
-import { ApiError } from "../api/http";
-import type { SessionResponse } from "../api/auth";
+import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
+import { ApiError } from '../api/http';
+import type { SessionResponse } from '../api/auth';
-const sessionQueryKey = ["auth", "session"] as const;
+const sessionQueryKey = ['auth', 'session'] as const;
function handleAuthError(error: unknown) {
if (error instanceof ApiError && error.status === 401) {
diff --git a/src/client/lib/router.tsx b/src/client/lib/router.tsx
index d3fcf7f..eaf3ed6 100644
--- a/src/client/lib/router.tsx
+++ b/src/client/lib/router.tsx
@@ -1,12 +1,12 @@
-import { useSyncExternalStore } from "react";
+import { useSyncExternalStore } from 'react';
const subscribe = (listener: () => void) => {
- window.addEventListener("popstate", listener);
- return () => window.removeEventListener("popstate", listener);
+ window.addEventListener('popstate', listener);
+ return () => window.removeEventListener('popstate', listener);
};
const getPathname = () => window.location.pathname;
-const getServerPathname = () => "/";
+const getServerPathname = () => '/';
export function usePathname() {
return useSyncExternalStore(subscribe, getPathname, getServerPathname);
@@ -14,8 +14,8 @@ export function usePathname() {
export function navigate(path: string, options: { replace?: boolean } = {}) {
if (path === window.location.pathname) return;
- const method = options.replace ? "replaceState" : "pushState";
- window.history[method](null, "", path);
- window.dispatchEvent(new PopStateEvent("popstate"));
+ const method = options.replace ? 'replaceState' : 'pushState';
+ window.history[method](null, '', path);
+ window.dispatchEvent(new PopStateEvent('popstate'));
window.scrollTo({ top: 0 });
}
diff --git a/src/client/main.tsx b/src/client/main.tsx
index 097d63a..8099f62 100644
--- a/src/client/main.tsx
+++ b/src/client/main.tsx
@@ -1,14 +1,14 @@
-import { QueryClientProvider } from "@tanstack/react-query";
-import { StrictMode } from "react";
-import { createRoot } from "react-dom/client";
-import App from "./App";
-import { queryClient } from "./lib/query-client";
-import "./styles.css";
+import { QueryClientProvider } from '@tanstack/react-query';
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import App from './App';
+import { queryClient } from './lib/query-client';
+import './styles.css';
-const root = document.getElementById("root");
+const root = document.getElementById('root');
if (!root) {
- throw new Error("Root element was not found");
+ throw new Error('Root element was not found');
}
createRoot(root).render(
diff --git a/src/client/pages/DashboardPage.tsx b/src/client/pages/DashboardPage.tsx
index 4a253d0..45b46eb 100644
--- a/src/client/pages/DashboardPage.tsx
+++ b/src/client/pages/DashboardPage.tsx
@@ -1,31 +1,21 @@
-import { type FormEvent, useState } from "react";
-import {
- ArrowRight,
- Database,
- History,
- Pencil,
- Power,
- PowerOff,
- RefreshCw,
- Trash2,
- Zap,
-} from "lucide-react";
-import type { Monitor, MonitorInput, MonitorMethod } from "../api/monitors";
-import { SiteIcon } from "../components/SiteIcon";
-import { useLogoutMutation } from "../queries/auth";
-import { navigate } from "../lib/router";
+import { type FormEvent, useState } from 'react';
+import { ArrowRight, Database, History, Pencil, Power, PowerOff, RefreshCw, Trash2, Zap } from 'lucide-react';
+import type { Monitor, MonitorInput, MonitorMethod } from '../api/monitors';
+import { SiteIcon } from '../components/SiteIcon';
+import { useLogoutMutation } from '../queries/auth';
+import { navigate } from '../lib/router';
import {
useCreateMonitorMutation,
useDeleteMonitorMutation,
useMonitorsQuery,
useRunCheckMutation,
useUpdateMonitorMutation,
-} from "../queries/monitors";
+} from '../queries/monitors';
const DEFAULT_INPUT: MonitorInput = {
- name: "",
- url: "https://",
- method: "GET",
+ name: '',
+ url: 'https://',
+ method: 'GET',
expectedStatus: 200,
intervalSeconds: 300,
timeoutMs: 10_000,
@@ -33,17 +23,17 @@ const DEFAULT_INPUT: MonitorInput = {
};
function formatCheckedAt(value: string | null) {
- if (!value) return "Not checked yet";
- return new Intl.DateTimeFormat("en", {
- dateStyle: "medium",
- timeStyle: "short",
+ if (!value) return 'Not checked yet';
+ return new Intl.DateTimeFormat('en', {
+ dateStyle: 'medium',
+ timeStyle: 'short',
}).format(new Date(value));
}
function monitorStatus(monitor: Monitor) {
- if (monitor.lastOk === true) return { label: "Up", className: "online" };
- if (monitor.lastOk === false) return { label: "Down", className: "offline" };
- return { label: "Not checked", className: "checking" };
+ if (monitor.lastOk === true) return { label: 'Up', className: 'online' };
+ if (monitor.lastOk === false) return { label: 'Down', className: 'offline' };
+ return { label: 'Not checked', className: 'checking' };
}
function errorMessage(error: unknown, fallback: string) {
@@ -119,10 +109,14 @@ export function DashboardPage() {
Production monitors
-
View status page
-
navigate("/settings")}>Settings
+
+ View status page
+
+
navigate('/settings')}>
+ Settings
+
logoutMutation.mutate()} disabled={logoutMutation.isPending}>
- {logoutMutation.isPending ? "Signing out…" : "Sign out"}
+ {logoutMutation.isPending ? 'Signing out…' : 'Sign out'}
@@ -136,7 +130,9 @@ export function DashboardPage() {
Track endpoint availability from Cloudflare's edge every five minutes.
{formOpen ? (
-
Close form
+
+ Close form
+
) : monitorsQuery.isSuccess && monitors.length > 0 ? (
Add monitor
@@ -145,77 +141,254 @@ export function DashboardPage() {
- Total monitors
{monitors.length} {monitors.filter((monitor) => monitor.enabled).length} enabled
- Currently up
{up} Latest checks succeeded
- Currently down
{down} Needs attention
+
+
Total monitors
+
{monitors.length}
+
{monitors.filter((monitor) => monitor.enabled).length} enabled
+
+
+
Currently up
+
{up}
+
Latest checks succeeded
+
+
+
Currently down
+
{down}
+
Needs attention
+
{formOpen && (
-
+
Checks run on the configured schedule, with a minimum interval of five minutes.
)}
-
Configured sites Latest result for each monitored endpoint.
-
void monitorsQuery.refetch()} disabled={monitorsQuery.isFetching} aria-label="Refresh monitors">
-
+
+
Configured sites
+
Latest result for each monitored endpoint.
+
+ void monitorsQuery.refetch()}
+ disabled={monitorsQuery.isFetching}
+ aria-label="Refresh monitors"
+ >
+
{monitorsQuery.isPending ? (
- {[0, 1, 2].map((item) =>
)}
+
+ {[0, 1, 2].map((item) => (
+
+
+
+
+
+ ))}
+
) : monitorsQuery.isError ? (
- Monitors could not be loaded {errorMessage(monitorsQuery.error, "Unknown request error")}
void monitorsQuery.refetch()}>Try again
+
+
Monitors could not be loaded
+
{errorMessage(monitorsQuery.error, 'Unknown request error')}
+
void monitorsQuery.refetch()}>
+ Try again
+
+
) : monitors.length === 0 ? (
- No monitors yet Add the first endpoint to start collecting availability checks.
{!formOpen &&
Add first site }
+
+
+
+
+
No monitors yet
+
Add the first endpoint to start collecting availability checks.
+ {!formOpen && (
+
+ Add first site
+
+ )}
+
) : (
-
Monitor Latest result Actions
+
+ Monitor
+ Latest result
+ Actions
+
{monitors.map((monitor) => {
const status = monitorStatus(monitor);
const checking = checkMutation.isPending && checkMutation.variables === monitor.id;
const deleting = deleteMutation.isPending && deleteMutation.variables === monitor.id;
const toggling = updateMutation.isPending && updateMutation.variables?.id === monitor.id;
return (
-
- navigate(`/monitors/${monitor.id}`)}>{monitor.name} {monitor.url} {monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m
- {checking ? "Checking" : status.label}{monitor.lastStatusCode === null ? "—" : `HTTP ${monitor.lastStatusCode}`} · {monitor.lastLatencyMs === null ? "—" : `${monitor.lastLatencyMs} ms`}{monitor.lastError ?? formatCheckedAt(monitor.lastCheckedAt)}
+
+
+
+
+
+
+ navigate(`/monitors/${monitor.id}`)}>
+ {monitor.name}
+
+ {monitor.url}
+
+ {monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m
+
+
+
+
+
+
+ {checking ? 'Checking' : status.label}
+
+
+ {monitor.lastStatusCode === null ? '—' : `HTTP ${monitor.lastStatusCode}`} ·{' '}
+ {monitor.lastLatencyMs === null ? '—' : `${monitor.lastLatencyMs} ms`}
+
+ {monitor.lastError ?? formatCheckedAt(monitor.lastCheckedAt)}
+
navigate(`/monitors/${monitor.id}`)}>
History
-
checkMutation.mutate(monitor.id)} disabled={checking} aria-busy={checking}>
-
- {checking ? "Checking" : "Check now"}
+ checkMutation.mutate(monitor.id)}
+ disabled={checking}
+ aria-busy={checking}
+ >
+
+ {checking ? 'Checking' : 'Check now'}
- openEditForm(monitor)} aria-label={`Edit ${monitor.name}`} title="Edit monitor">
+ openEditForm(monitor)}
+ aria-label={`Edit ${monitor.name}`}
+ title="Edit monitor"
+ >
- updateMutation.mutate({ id: monitor.id, input: { enabled: !monitor.enabled } })} disabled={toggling} aria-label={`${monitor.enabled ? "Disable" : "Enable"} ${monitor.name}`} title={`${monitor.enabled ? "Disable" : "Enable"} monitor`}>
+ updateMutation.mutate({ id: monitor.id, input: { enabled: !monitor.enabled } })}
+ disabled={toggling}
+ aria-label={`${monitor.enabled ? 'Disable' : 'Enable'} ${monitor.name}`}
+ title={`${monitor.enabled ? 'Disable' : 'Enable'} monitor`}
+ >
{monitor.enabled ? : }
- handleDelete(monitor)} disabled={deleting} aria-label={`Delete ${monitor.name}`} title="Delete monitor" aria-busy={deleting}>
+ handleDelete(monitor)}
+ disabled={deleting}
+ aria-label={`Delete ${monitor.name}`}
+ title="Delete monitor"
+ aria-busy={deleting}
+ >
@@ -226,7 +399,10 @@ export function DashboardPage() {
)}
- Cloudflare Workers + D1 Automatic refresh every minute
+
+ Cloudflare Workers + D1
+ Automatic refresh every minute
+
);
}
diff --git a/src/client/pages/LoginPage.tsx b/src/client/pages/LoginPage.tsx
index 49638ef..a1d8cef 100644
--- a/src/client/pages/LoginPage.tsx
+++ b/src/client/pages/LoginPage.tsx
@@ -1,28 +1,23 @@
-import { type FormEvent, useEffect, useState } from "react";
-import { Zap } from "lucide-react";
-import { navigate } from "../lib/router";
-import { useLoginMutation, useSessionQuery } from "../queries/auth";
+import { type FormEvent, useEffect, useState } from 'react';
+import { Zap } from 'lucide-react';
+import { navigate } from '../lib/router';
+import { useLoginMutation, useSessionQuery } from '../queries/auth';
export function LoginPage() {
- const [password, setPassword] = useState("");
+ const [password, setPassword] = useState('');
const sessionQuery = useSessionQuery();
const loginMutation = useLoginMutation();
useEffect(() => {
- if (sessionQuery.data?.authenticated) navigate("/dashboard", { replace: true });
+ if (sessionQuery.data?.authenticated) navigate('/dashboard', { replace: true });
}, [sessionQuery.data?.authenticated]);
function handleSubmit(event: FormEvent) {
event.preventDefault();
- loginMutation.mutate(
- { password },
- { onSuccess: () => navigate("/dashboard", { replace: true }) },
- );
+ loginMutation.mutate({ password }, { onSuccess: () => navigate('/dashboard', { replace: true }) });
}
- const errorMessage = loginMutation.error instanceof Error
- ? loginMutation.error.message
- : "Unable to sign in";
+ const errorMessage = loginMutation.error instanceof Error ? loginMutation.error.message : 'Unable to sign in';
return (
@@ -49,16 +44,17 @@ export function LoginPage() {
onChange={(event) => setPassword(event.target.value)}
minLength={8}
required
- autoFocus
/>
{loginMutation.isError && (
-
{errorMessage}
+
+ {errorMessage}
+
)}
- {loginMutation.isPending ? "Signing in…" : "Sign in"}
+ {loginMutation.isPending ? 'Signing in…' : 'Sign in'}
diff --git a/src/client/pages/MonitorDetailPage.tsx b/src/client/pages/MonitorDetailPage.tsx
index 62ffe39..93a0931 100644
--- a/src/client/pages/MonitorDetailPage.tsx
+++ b/src/client/pages/MonitorDetailPage.tsx
@@ -1,18 +1,18 @@
-import { ArrowLeft, BellOff, CheckCircle2, Clock3, ExternalLink, RefreshCw, Zap } from "lucide-react";
-import { LatencySparkline } from "../components/charts/LatencySparkline";
-import { UptimeBar } from "../components/charts/UptimeBar";
-import { navigate } from "../lib/router";
-import { useLogoutMutation } from "../queries/auth";
+import { ArrowLeft, BellOff, CheckCircle2, Clock3, ExternalLink, RefreshCw, Zap } from 'lucide-react';
+import { LatencySparkline } from '../components/charts/LatencySparkline';
+import { UptimeBar } from '../components/charts/UptimeBar';
+import { navigate } from '../lib/router';
+import { useLogoutMutation } from '../queries/auth';
import {
useMonitorChecksQuery,
useMonitorIncidentsQuery,
useMonitorQuery,
useMonitorStatsQuery,
useRunCheckMutation,
-} from "../queries/monitors";
+} from '../queries/monitors';
function formatDate(value: string) {
- return new Intl.DateTimeFormat("en", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
+ return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value));
}
function formatDuration(ms: number | null, startedAt: string) {
@@ -24,9 +24,9 @@ function formatDuration(ms: number | null, startedAt: string) {
}
function statusDetails(lastOk: boolean | null) {
- if (lastOk === true) return { label: "Operational", className: "online" };
- if (lastOk === false) return { label: "Down", className: "offline" };
- return { label: "Awaiting first check", className: "checking" };
+ if (lastOk === true) return { label: 'Operational', className: 'online' };
+ if (lastOk === false) return { label: 'Down', className: 'offline' };
+ return { label: 'Awaiting first check', className: 'checking' };
}
export function MonitorDetailPage({ id }: { id: number }) {
@@ -42,40 +42,188 @@ export function MonitorDetailPage({ id }: { id: number }) {
const status = statusDetails(monitor?.lastOk ?? null);
const openIncident = incidents.find((incident) => incident.resolvedAt === null);
- if (monitorQuery.isPending) return
;
- if (monitorQuery.isError || !monitor) return (
-
Monitor not found The requested monitor could not be loaded.
navigate("/dashboard")}>Return to dashboard
- );
+ if (monitorQuery.isPending)
+ return (
+
+
+
Loading monitor history…
+
+ );
+ if (monitorQuery.isError || !monitor)
+ return (
+
+
Monitor not found
+
The requested monitor could not be loaded.
+
navigate('/dashboard')}>
+ Return to dashboard
+
+
+ );
return (
-
+
- navigate("/dashboard")}> All monitors
+ navigate('/dashboard')}>
+ All monitors
+
-
- {status.label} {!monitor.alertsEnabled && Alerts muted } checkMutation.mutate(id)} disabled={checkMutation.isPending}>{checkMutation.isPending ? "Checking…" : "Check now"}
+
+
+
+
+ {status.label}
+
+ {!monitor.alertsEnabled && (
+
+ Alerts muted
+
+ )}
+ checkMutation.mutate(id)} disabled={checkMutation.isPending}>
+ {checkMutation.isPending ? 'Checking…' : 'Check now'}
+
+
- {(["24h", "7d", "30d", "90d"] as const).map((key) => {
+ {(['24h', '7d', '30d', '90d'] as const).map((key) => {
const window = statsQuery.data?.windows[key];
- return {key} uptime
{window?.uptimePct == null ? "—" : `${window.uptimePct.toFixed(3)}%`} {window?.totalChecks ?? 0} checks · {window?.avgLatencyMs ?? "—"} ms avg ;
+ return (
+
+ {key} uptime
+ {window?.uptimePct == null ? '—' : `${window.uptimePct.toFixed(3)}%`}
+
+ {window?.totalChecks ?? 0} checks · {window?.avgLatencyMs ?? '—'} ms avg
+
+
+ );
})}
- Current incident
{openIncident ? formatDuration(null, openIncident.startedAt) : "None"} {openIncident ? `Open since ${formatDate(openIncident.startedAt)}` : "Everything is operational"}
+
+ Current incident
+ {openIncident ? formatDuration(null, openIncident.startedAt) : 'None'}
+ {openIncident ? `Open since ${formatDate(openIncident.startedAt)}` : 'Everything is operational'}
+
-
Last {checks.length} checks
-
Availability
Recent uptime {checks.filter((check) => check.ok).length}/{checks.length} successful
+
+
+
+
Response time
+
Latency
+
+
Last {checks.length} checks
+
+
+
+
+
+
+
Availability
+
Recent uptime
+
+
+ {checks.filter((check) => check.ok).length}/{checks.length} successful
+
+
+
+
-
Event stream
Recent checks {Math.min(checks.length, 20)} shown Status Response Latency Checked {checks.slice(0, 20).map((check) => {check.ok ? "Up" : "Down"}{check.statusCode ? `HTTP ${check.statusCode}` : check.error ?? "Failed"}{check.latencyMs} ms {formatDate(check.checkedAt)} )}
{checks.length === 0 &&
No checks recorded.
}
-
{incidents.length} recorded {incidents.map((incident) =>
{incident.resolvedAt ? : } {incident.resolvedAt ? "Resolved incident" : "Incident in progress"} {incident.startError ?? (incident.startStatusCode ? `HTTP ${incident.startStatusCode}` : "Endpoint became unavailable")}
{formatDate(incident.startedAt)} · {formatDuration(incident.durationMs, incident.startedAt)} )}{incidents.length === 0 &&
No downtime incidents recorded.
}
+
+
+
+
Event stream
+
Recent checks
+
+
{Math.min(checks.length, 20)} shown
+
+ {/* Keyboard focus makes this horizontally scrollable region accessible without a pointer. */}
+ {/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
+
+
+
+
+ Status
+ Response
+ Latency
+ Checked
+
+
+
+ {checks.slice(0, 20).map((check) => (
+
+
+
+
+ {check.ok ? 'Up' : 'Down'}
+
+
+
+ {check.statusCode ? `HTTP ${check.statusCode}` : (check.error ?? 'Failed')}
+
+ {check.latencyMs} ms
+ {formatDate(check.checkedAt)}
+
+ ))}
+
+
+ {checks.length === 0 && No checks recorded.
}
+
+
+
+
+
+
{incidents.length} recorded
+
+
+ {incidents.map((incident) => (
+
+ {incident.resolvedAt ? : }
+
+
{incident.resolvedAt ? 'Resolved incident' : 'Incident in progress'}
+
+ {incident.startError ??
+ (incident.startStatusCode ? `HTTP ${incident.startStatusCode}` : 'Endpoint became unavailable')}
+
+
+ {formatDate(incident.startedAt)} · {formatDuration(incident.durationMs, incident.startedAt)}
+
+
+
+ ))}
+ {incidents.length === 0 &&
No downtime incidents recorded.
}
+
+
diff --git a/src/client/pages/SettingsPage.tsx b/src/client/pages/SettingsPage.tsx
index 6f1c9ff..0fe4328 100644
--- a/src/client/pages/SettingsPage.tsx
+++ b/src/client/pages/SettingsPage.tsx
@@ -1,12 +1,16 @@
-import { type FormEvent, useState } from "react";
-import { ArrowLeft, BellRing, Send, Zap } from "lucide-react";
-import type { NotificationSettings } from "../api/settings";
-import { navigate } from "../lib/router";
-import { useLogoutMutation } from "../queries/auth";
-import { useNotificationSettingsQuery, useTestNotificationWebhookMutation, useUpdateNotificationSettingsMutation } from "../queries/settings";
+import { type FormEvent, useState } from 'react';
+import { ArrowLeft, BellRing, Send, Zap } from 'lucide-react';
+import type { NotificationSettings } from '../api/settings';
+import { navigate } from '../lib/router';
+import { useLogoutMutation } from '../queries/auth';
+import {
+ useNotificationSettingsQuery,
+ useTestNotificationWebhookMutation,
+ useUpdateNotificationSettingsMutation,
+} from '../queries/settings';
function SettingsForm({ settings }: { settings: NotificationSettings }) {
- const [webhookUrl, setWebhookUrl] = useState(settings.webhookUrl ?? "");
+ const [webhookUrl, setWebhookUrl] = useState(settings.webhookUrl ?? '');
const [webhookEnabled, setWebhookEnabled] = useState(settings.webhookEnabled);
const updateMutation = useUpdateNotificationSettingsMutation();
const testMutation = useTestNotificationWebhookMutation();
@@ -16,30 +20,109 @@ function SettingsForm({ settings }: { settings: NotificationSettings }) {
updateMutation.mutate({ webhookUrl: webhookUrl.trim() || null, webhookEnabled });
}
- return
;
+ return (
+
+ );
}
export function SettingsPage() {
const settingsQuery = useNotificationSettingsQuery();
const logoutMutation = useLogoutMutation();
- return
-
-
navigate("/dashboard")}> Dashboard Integrations
Notifications Route monitor transitions to Slack, Discord, or any service that accepts JSON webhooks.
- Incident webhook Upwatch sends a compact JSON payload for down and recovery events. Delivery failures never interrupt monitoring.
{settingsQuery.isPending ? Loading settings…
: settingsQuery.isError ? Unable to load notification settings.
: }
- Payload preview
{`{
+ return (
+
+
+
+ navigate('/dashboard')}>
+ Dashboard
+
+
+ Integrations
+ Notifications
+ Route monitor transitions to Slack, Discord, or any service that accepts JSON webhooks.
+
+
+
+
+
+
+
+
Incident webhook
+
Upwatch sends a compact JSON payload for down and recovery events. Delivery failures never interrupt monitoring.
+
+
+ {settingsQuery.isPending ? (
+ Loading settings…
+ ) : settingsQuery.isError ? (
+ Unable to load notification settings.
+ ) : (
+
+ )}
+
+
+ Payload preview
+ {`{
"event": "down",
"monitor": { "id": 12, "name": "API", "url": "https://api.example.com" },
"statusCode": 500,
"error": "Expected HTTP 200, received 500",
"at": "2026-08-28T03:25:00.000Z"
-}`}
-
-
;
+}`}
+
+
+
+ );
}
diff --git a/src/client/pages/StatusPage.tsx b/src/client/pages/StatusPage.tsx
index 399bca0..894217b 100644
--- a/src/client/pages/StatusPage.tsx
+++ b/src/client/pages/StatusPage.tsx
@@ -1,48 +1,48 @@
-import { useEffect, useState } from "react";
-import { Activity, CircleCheck, Database, RefreshCw, TriangleAlert, Zap } from "lucide-react";
-import type { PublicOverallStatus, PublicServiceStatus } from "../api/status";
-import { SiteIcon } from "../components/SiteIcon";
-import { StatusHistoryBar } from "../components/StatusHistoryBar";
-import { navigate } from "../lib/router";
-import { useSessionQuery } from "../queries/auth";
-import { useStatusQuery } from "../queries/status";
+import { useEffect, useState } from 'react';
+import { Activity, CircleCheck, Database, RefreshCw, TriangleAlert, Zap } from 'lucide-react';
+import type { PublicOverallStatus, PublicServiceStatus } from '../api/status';
+import { SiteIcon } from '../components/SiteIcon';
+import { StatusHistoryBar } from '../components/StatusHistoryBar';
+import { navigate } from '../lib/router';
+import { useSessionQuery } from '../queries/auth';
+import { useStatusQuery } from '../queries/status';
const OVERALL_COPY: Record
= {
operational: {
- title: "All systems operational",
- detail: "Every monitored service is responding normally.",
+ title: 'All systems operational',
+ detail: 'Every monitored service is responding normally.',
},
degraded: {
- title: "Some systems are degraded",
- detail: "One or more services are currently experiencing disruption.",
+ title: 'Some systems are degraded',
+ detail: 'One or more services are currently experiencing disruption.',
},
down: {
- title: "Major service disruption",
- detail: "All reporting services are currently unavailable.",
+ title: 'Major service disruption',
+ detail: 'All reporting services are currently unavailable.',
},
};
const SERVICE_STATUS: Record = {
- up: { label: "Operational", className: "online" },
- down: { label: "Down", className: "offline" },
- unknown: { label: "Awaiting data", className: "checking" },
+ up: { label: 'Operational', className: 'online' },
+ down: { label: 'Down', className: 'offline' },
+ unknown: { label: 'Awaiting data', className: 'checking' },
};
function errorMessage(error: unknown) {
- return error instanceof Error ? error.message : "Unknown request error";
+ return error instanceof Error ? error.message : 'Unknown request error';
}
function relativeUpdate(updatedAt: number, now: number) {
const seconds = Math.max(0, Math.floor((now - updatedAt) / 1_000));
- if (seconds < 5) return "Updated just now";
+ if (seconds < 5) return 'Updated just now';
if (seconds < 60) return `Updated ${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
return `Updated ${minutes}m ago`;
}
function OverallIcon({ status }: { status: PublicOverallStatus }) {
- if (status === "operational") return ;
- if (status === "degraded") return ;
+ if (status === 'operational') return ;
+ if (status === 'degraded') return ;
return ;
}
@@ -68,9 +68,9 @@ export function StatusPage() {
navigate(sessionQuery.data?.authenticated ? "/dashboard" : "/login")}
+ onClick={() => navigate(sessionQuery.data?.authenticated ? '/dashboard' : '/login')}
>
- {sessionQuery.data?.authenticated ? "Dashboard" : "Sign in"}
+ {sessionQuery.data?.authenticated ? 'Dashboard' : 'Sign in'}
@@ -85,19 +85,33 @@ export function StatusPage() {
{statusQuery.isPending ? (
-
{[0, 1, 2].map((item) =>
)}
+
+ {[0, 1, 2].map((item) => (
+
+
+
+
+
+ ))}
+
) : statusQuery.isError || !status ? (
-
+
+
+
Status could not be loaded
{errorMessage(statusQuery.error)}
- void statusQuery.refetch()}>Try again
+ void statusQuery.refetch()}>
+ Try again
+
) : (
<>
-
+
+
+
{OVERALL_COPY[status.overall].title}
{OVERALL_COPY[status.overall].detail}
@@ -111,14 +125,22 @@ export function StatusPage() {
Services
Availability is calculated from checks collected over the last 90 days.
- void statusQuery.refetch()} disabled={statusQuery.isFetching} aria-label="Refresh service status">
-
+ void statusQuery.refetch()}
+ disabled={statusQuery.isFetching}
+ aria-label="Refresh service status"
+ >
+
{status.services.length === 0 ? (
-
+
+
+
No public services yet
Service health will appear here after monitoring is enabled.
@@ -129,15 +151,20 @@ export function StatusPage() {
return (
-
+
+
+
{service.name}
- {serviceStatus.label}
+
+
+ {serviceStatus.label}
+
90-day uptime
- {service.uptime90d === null ? "—" : `${service.uptime90d.toFixed(1)}%`}
+ {service.uptime90d === null ? '—' : `${service.uptime90d.toFixed(1)}%`}
@@ -152,7 +179,9 @@ export function StatusPage() {
Powered by upwatch
- Monitoring from Cloudflare's edge
+
+ Monitoring from Cloudflare's edge
+
);
diff --git a/src/client/queries/auth.ts b/src/client/queries/auth.ts
index 92d6e62..0a562bc 100644
--- a/src/client/queries/auth.ts
+++ b/src/client/queries/auth.ts
@@ -1,10 +1,10 @@
-import { queryOptions, useMutation, useQuery } from "@tanstack/react-query";
-import { getSession, login, logout, type LoginInput, type SessionResponse } from "../api/auth";
-import { queryClient } from "../lib/query-client";
+import { queryOptions, useMutation, useQuery } from '@tanstack/react-query';
+import { getSession, login, logout, type LoginInput, type SessionResponse } from '../api/auth';
+import { queryClient } from '../lib/query-client';
export const authKeys = {
- all: ["auth"] as const,
- session: () => [...authKeys.all, "session"] as const,
+ all: ['auth'] as const,
+ session: () => [...authKeys.all, 'session'] as const,
};
export const sessionQueryOptions = () =>
diff --git a/src/client/queries/health.ts b/src/client/queries/health.ts
index 620edb5..feb635f 100644
--- a/src/client/queries/health.ts
+++ b/src/client/queries/health.ts
@@ -1,9 +1,9 @@
-import { queryOptions, useQuery } from "@tanstack/react-query";
-import { getHealth } from "../api/health";
+import { queryOptions, useQuery } from '@tanstack/react-query';
+import { getHealth } from '../api/health';
export const healthKeys = {
- all: ["health"] as const,
- status: () => [...healthKeys.all, "status"] as const,
+ all: ['health'] as const,
+ status: () => [...healthKeys.all, 'status'] as const,
};
export const healthQueryOptions = () =>
diff --git a/src/client/queries/monitors.ts b/src/client/queries/monitors.ts
index cf2cf7d..1f25e93 100644
--- a/src/client/queries/monitors.ts
+++ b/src/client/queries/monitors.ts
@@ -1,4 +1,4 @@
-import { queryOptions, useMutation, useQuery } from "@tanstack/react-query";
+import { queryOptions, useMutation, useQuery } from '@tanstack/react-query';
import {
createMonitor,
deleteMonitor,
@@ -10,16 +10,16 @@ import {
runMonitorCheck,
updateMonitor,
type MonitorInput,
-} from "../api/monitors";
-import { queryClient } from "../lib/query-client";
+} from '../api/monitors';
+import { queryClient } from '../lib/query-client';
export const monitorKeys = {
- all: ["monitors"] as const,
- list: () => [...monitorKeys.all, "list"] as const,
- detail: (id: number) => [...monitorKeys.all, "detail", id] as const,
- checks: (id: number) => [...monitorKeys.all, "checks", id] as const,
- stats: (id: number) => [...monitorKeys.all, "stats", id] as const,
- incidents: (id: number) => [...monitorKeys.all, "incidents", id] as const,
+ all: ['monitors'] as const,
+ list: () => [...monitorKeys.all, 'list'] as const,
+ detail: (id: number) => [...monitorKeys.all, 'detail', id] as const,
+ checks: (id: number) => [...monitorKeys.all, 'checks', id] as const,
+ stats: (id: number) => [...monitorKeys.all, 'stats', id] as const,
+ incidents: (id: number) => [...monitorKeys.all, 'incidents', id] as const,
};
export const monitorsQueryOptions = () =>
@@ -88,8 +88,7 @@ export function useCreateMonitorMutation() {
export function useUpdateMonitorMutation() {
return useMutation({
- mutationFn: ({ id, input }: { id: number; input: Partial }) =>
- updateMonitor(id, input),
+ mutationFn: ({ id, input }: { id: number; input: Partial }) => updateMonitor(id, input),
onSuccess: invalidateMonitors,
});
}
diff --git a/src/client/queries/settings.ts b/src/client/queries/settings.ts
index f677003..45537b8 100644
--- a/src/client/queries/settings.ts
+++ b/src/client/queries/settings.ts
@@ -1,15 +1,15 @@
-import { useMutation, useQuery } from "@tanstack/react-query";
+import { useMutation, useQuery } from '@tanstack/react-query';
import {
getNotificationSettings,
testNotificationWebhook,
updateNotificationSettings,
type NotificationSettingsInput,
-} from "../api/settings";
-import { queryClient } from "../lib/query-client";
+} from '../api/settings';
+import { queryClient } from '../lib/query-client';
export const settingsKeys = {
- all: ["settings"] as const,
- notifications: () => [...settingsKeys.all, "notifications"] as const,
+ all: ['settings'] as const,
+ notifications: () => [...settingsKeys.all, 'notifications'] as const,
};
export function useNotificationSettingsQuery() {
diff --git a/src/client/queries/status.ts b/src/client/queries/status.ts
index 8bc96c6..7eed9b5 100644
--- a/src/client/queries/status.ts
+++ b/src/client/queries/status.ts
@@ -1,8 +1,8 @@
-import { useQuery } from "@tanstack/react-query";
-import { getStatus } from "../api/status";
+import { useQuery } from '@tanstack/react-query';
+import { getStatus } from '../api/status';
export const statusKeys = {
- all: ["public-status"] as const,
+ all: ['public-status'] as const,
};
export function useStatusQuery() {
diff --git a/src/client/styles.css b/src/client/styles.css
index f3d5448..fcf55d2 100644
--- a/src/client/styles.css
+++ b/src/client/styles.css
@@ -1,8 +1,8 @@
-@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap");
-@import "tailwindcss";
+@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap');
+@import 'tailwindcss';
:root {
- font-family: "DM Sans", "Helvetica Neue", sans-serif;
+ font-family: 'DM Sans', 'Helvetica Neue', sans-serif;
color: #171717;
background: #fff;
font-synthesis: none;
@@ -17,200 +17,1061 @@
--night: #1c1c1c;
}
-* { box-sizing: border-box; }
-html { scroll-behavior: smooth; }
-body { margin: 0; min-width: 320px; min-height: 100vh; background: #fff; }
-button, input, select { font: inherit; }
-a { color: inherit; text-decoration: none; }
-button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--primary-deep); outline-offset: 3px; }
-button { color: inherit; }
-
-.full-page-loading { display: grid; place-items: center; align-content: center; gap: 14px; min-height: 100dvh; color: var(--muted); }
-.full-page-loading p { margin: 0; font-size: 13px; }
-.loading-mark { width: 32px; height: 32px; color: var(--primary-deep); animation: loading-pulse 1.2s ease-in-out infinite; }
-
-.dashboard-shell { min-height: 100dvh; background: linear-gradient(#fff 0, #fff 68px, #fcfcfc 68px); }
-.dashboard-header { position: sticky; z-index: 20; top: 0; border-bottom: 1px solid #ededed; background: rgb(255 255 255 / 0.94); backdrop-filter: blur(14px); }
-.dashboard-header-inner { display: flex; align-items: center; justify-content: space-between; width: min(1280px, calc(100% - 48px)); height: 68px; margin: 0 auto; }
-.brand { display: inline-flex; align-items: center; gap: 8px; font-size: 20px; font-weight: 600; letter-spacing: -0.6px; }
-.brand-button { padding: 0; border: 0; background: transparent; cursor: pointer; }
-.brand-mark { width: 26px; height: 26px; color: var(--primary-deep); }
-.nav-actions { display: flex; align-items: center; gap: 20px; }
-.header-context { padding-right: 20px; border-right: 1px solid #e5e5e5; font-size: 13px; color: var(--muted); }
-.nav-auth { padding: 0; border: 0; font-size: 13px; font-weight: 500; color: #525252; background: transparent; cursor: pointer; transition: color 160ms ease; }
-.nav-auth:hover:not(:disabled) { color: var(--primary-deep); }
-.nav-auth:disabled { cursor: wait; opacity: 0.55; }
-
-.dashboard-main { width: min(1280px, calc(100% - 48px)); margin: 0 auto; padding: 56px 0 72px; }
-.dashboard-intro { display: flex; align-items: flex-end; justify-content: space-between; gap: 32px; }
-.overline { margin: 0 0 8px; font: 500 12px/1.4 "IBM Plex Mono", monospace; letter-spacing: 0.08em; text-transform: uppercase; color: #55816e; }
-.dashboard-intro h1 { margin: 0; font-size: 40px; font-weight: 500; line-height: 1.1; letter-spacing: -1.5px; }
-.dashboard-intro > div > p:last-child { margin: 12px 0 0; font-size: 16px; color: var(--muted); }
-.primary-button, .secondary-button {
- display: inline-flex; align-items: center; justify-content: center; gap: 9px; min-height: 42px; padding: 8px 16px;
- border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; white-space: nowrap;
- transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
+* {
+ box-sizing: border-box;
}
-.primary-button { border: 1px solid #35c586; color: var(--ink); background: var(--primary); box-shadow: 0 1px 2px rgb(0 0 0 / 0.08), inset 0 1px rgb(255 255 255 / 0.2); }
-.primary-button:hover:not(:disabled) { background: #36c487; transform: translateY(-1px); }
-.secondary-button { border: 1px solid #cfcfcf; color: #444; background: #fff; }
-.secondary-button:hover:not(:disabled) { border-color: #aaa; background: var(--soft); }
-.primary-button:active:not(:disabled), .secondary-button:active:not(:disabled) { transform: translateY(1px); }
-.primary-button:disabled, .secondary-button:disabled { cursor: wait; opacity: 0.6; }
-.primary-button svg { width: 16px; height: 16px; }
-
-.metric-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 40px; }
-.metric-card { min-height: 142px; padding: 24px; border: 1px solid #e3e3e3; border-radius: 8px; background: #fff; }
-.metric-card p { margin: 0 0 20px; font-size: 13px; color: var(--muted); }
-.metric-card strong { display: block; font-size: 32px; font-weight: 500; line-height: 1; letter-spacing: -1px; }
-.metric-card span { display: block; margin-top: 10px; font-size: 13px; color: var(--faint); }
-
-.monitor-form-panel { margin-top: 24px; padding: 28px; border: 1px solid #dcdcdc; border-radius: 8px; background: #fff; box-shadow: 0 8px 28px rgb(29 74 54 / 0.06); }
-.form-panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 32px; padding-bottom: 24px; border-bottom: 1px solid #ededed; }
-.form-panel-heading h2 { margin: 0; font-size: 22px; font-weight: 500; letter-spacing: -0.4px; }
-.form-panel-heading > p { max-width: 480px; margin: 0; font-size: 13px; line-height: 1.55; color: var(--muted); }
-.monitor-form { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px 16px; margin-top: 24px; }
-.field { display: grid; gap: 8px; }
-.field-name { grid-column: span 1; }
-.field-url { grid-column: span 3; }
-.field > span { font-size: 13px; font-weight: 500; color: #353535; }
-.field input, .field select {
- width: 100%; min-height: 42px; padding: 8px 12px; border: 1px solid #cfcfcf; border-radius: 6px;
- font-size: 14px; color: var(--ink); background: #fff; box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.025);
+html {
+ scroll-behavior: smooth;
+}
+body {
+ margin: 0;
+ min-width: 320px;
+ min-height: 100vh;
+ background: #fff;
+}
+button,
+input,
+select {
+ font: inherit;
+}
+a {
+ color: inherit;
+ text-decoration: none;
+}
+button:focus-visible,
+a:focus-visible,
+input:focus-visible,
+select:focus-visible {
+ outline: 2px solid var(--primary-deep);
+ outline-offset: 3px;
+}
+button {
+ color: inherit;
}
-.field input:hover, .field select:hover { border-color: #aaa; }
-.field input:focus, .field select:focus { border-color: var(--primary-deep); outline: 0; box-shadow: 0 0 0 3px rgb(36 180 126 / 0.14); }
-.toggle-field { display: flex; align-items: center; gap: 9px; align-self: end; min-height: 42px; font-size: 13px; color: #444; cursor: pointer; }
-.toggle-field input { width: 16px; height: 16px; accent-color: var(--primary-deep); }
-.form-actions { display: flex; justify-content: flex-end; gap: 10px; align-self: end; grid-column: span 3; }
-.form-actions.compact-actions { grid-column: span 2; }
-.form-error { grid-column: 1 / -1; margin: 0; padding: 10px 12px; border: 1px solid #efcaca; border-radius: 6px; font-size: 13px; color: #9f2f2f; background: #fff6f6; }
-.services-panel { margin-top: 24px; border: 1px solid #dedede; border-radius: 8px; overflow: hidden; background: #fff; }
-.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 78px; padding: 16px 20px; border-bottom: 1px solid #e8e8e8; }
-.panel-heading h2 { margin: 0; font-size: 18px; font-weight: 500; }
-.panel-heading p { margin: 4px 0 0; font-size: 13px; color: var(--muted); }
-.icon-button { display: grid; place-items: center; width: 34px; height: 34px; padding: 0; border: 1px solid #d8d8d8; border-radius: 6px; color: #666; background: #fff; cursor: pointer; }
-.icon-button:hover:not(:disabled) { color: #222; border-color: #aaa; }
-.icon-button:disabled { cursor: wait; opacity: 0.6; }
-.icon-button svg { width: 15px; height: 15px; }
-.is-spinning { animation: spin 900ms linear infinite; }
-.services-title, .service-row { display: grid; grid-template-columns: minmax(320px, 1.35fr) minmax(220px, 0.8fr) minmax(340px, 1fr); align-items: center; column-gap: 20px; }
-.services-title { min-height: 40px; padding: 0 20px; border-bottom: 1px solid #e8e8e8; font-size: 12px; color: #888; background: #fafafa; }
-.services-title span:last-child { justify-self: start; }
-.service-row { min-height: 112px; padding: 18px 20px; transition: opacity 160ms ease, background 160ms ease; }
-.service-row + .service-row { border-top: 1px solid #ededed; }
-.service-row:hover { background: #fdfdfd; }
-.service-row.is-disabled { opacity: 0.55; background: #fafafa; }
-.service-name { display: flex; align-items: center; gap: 14px; min-width: 0; }
-.service-name > div { min-width: 0; }
-.service-icon { display: grid; place-items: center; flex: 0 0 auto; width: 40px; height: 40px; border: 1px solid #dedede; border-radius: 6px; color: #555; background: #fafafa; }
-.service-icon svg { width: 19px; height: 19px; }
-.service-icon img { width: 22px; height: 22px; border-radius: 4px; object-fit: contain; }
-.service-name strong, .service-name small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.service-name strong { font-size: 15px; font-weight: 500; }
-.monitor-name-link { display: block; max-width: 100%; padding: 0; overflow: hidden; border: 0; font-size: 15px; font-weight: 500; text-align: left; text-overflow: ellipsis; white-space: nowrap; background: transparent; cursor: pointer; }
-.monitor-name-link:hover { color: #16885b; text-decoration: underline; text-underline-offset: 3px; }
-.service-name small { margin-top: 4px; font: 400 12px/1.4 "IBM Plex Mono", monospace; color: #888; }
-.monitor-meta { display: block; margin-top: 5px; font-size: 12px; color: #aaa; }
-.monitor-result { display: grid; justify-items: start; gap: 6px; min-width: 0; }
-.monitor-result code { font: 400 12px/1.4 "IBM Plex Mono", monospace; color: #606060; }
-.monitor-result small { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #999; }
-.row-status { display: inline-flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 500; color: #777; }
-.row-status i { width: 7px; height: 7px; border-radius: 50%; background: #d05a5a; }
-.row-status.online { color: #16885b; }
-.row-status.online i { background: var(--primary-deep); box-shadow: 0 0 0 3px rgb(62 207 142 / 0.12); }
-.row-status.offline { color: #ae3d3d; }
-.row-status.checking { color: #8b7722; }
-.row-status.checking i { background: #d7bd53; animation: blink 1.1s ease-in-out infinite; }
+.full-page-loading {
+ display: grid;
+ place-items: center;
+ align-content: center;
+ gap: 14px;
+ min-height: 100dvh;
+ color: var(--muted);
+}
+.full-page-loading p {
+ margin: 0;
+ font-size: 13px;
+}
+.loading-mark {
+ width: 32px;
+ height: 32px;
+ color: var(--primary-deep);
+ animation: loading-pulse 1.2s ease-in-out infinite;
+}
+
+.dashboard-shell {
+ min-height: 100dvh;
+ background: linear-gradient(#fff 0, #fff 68px, #fcfcfc 68px);
+}
+.dashboard-header {
+ position: sticky;
+ z-index: 20;
+ top: 0;
+ border-bottom: 1px solid #ededed;
+ background: rgb(255 255 255 / 0.94);
+ backdrop-filter: blur(14px);
+}
+.dashboard-header-inner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: min(1280px, calc(100% - 48px));
+ height: 68px;
+ margin: 0 auto;
+}
+.brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 20px;
+ font-weight: 600;
+ letter-spacing: -0.6px;
+}
+.brand-button {
+ padding: 0;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+}
+.brand-mark {
+ width: 26px;
+ height: 26px;
+ color: var(--primary-deep);
+}
+.nav-actions {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+}
+.header-context {
+ padding-right: 20px;
+ border-right: 1px solid #e5e5e5;
+ font-size: 13px;
+ color: var(--muted);
+}
+.nav-auth {
+ padding: 0;
+ border: 0;
+ font-size: 13px;
+ font-weight: 500;
+ color: #525252;
+ background: transparent;
+ cursor: pointer;
+ transition: color 160ms ease;
+}
+.nav-auth:hover:not(:disabled) {
+ color: var(--primary-deep);
+}
+.nav-auth:disabled {
+ cursor: wait;
+ opacity: 0.55;
+}
+
+.dashboard-main {
+ width: min(1280px, calc(100% - 48px));
+ margin: 0 auto;
+ padding: 56px 0 72px;
+}
+.dashboard-intro {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 32px;
+}
+.overline {
+ margin: 0 0 8px;
+ font:
+ 500 12px/1.4 'IBM Plex Mono',
+ monospace;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: #55816e;
+}
+.dashboard-intro h1 {
+ margin: 0;
+ font-size: 40px;
+ font-weight: 500;
+ line-height: 1.1;
+ letter-spacing: -1.5px;
+}
+.dashboard-intro > div > p:last-child {
+ margin: 12px 0 0;
+ font-size: 16px;
+ color: var(--muted);
+}
+.primary-button,
+.secondary-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 9px;
+ min-height: 42px;
+ padding: 8px 16px;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ white-space: nowrap;
+ transition:
+ border-color 160ms ease,
+ background 160ms ease,
+ transform 160ms ease;
+}
+.primary-button {
+ border: 1px solid #35c586;
+ color: var(--ink);
+ background: var(--primary);
+ box-shadow:
+ 0 1px 2px rgb(0 0 0 / 0.08),
+ inset 0 1px rgb(255 255 255 / 0.2);
+}
+.primary-button:hover:not(:disabled) {
+ background: #36c487;
+ transform: translateY(-1px);
+}
+.secondary-button {
+ border: 1px solid #cfcfcf;
+ color: #444;
+ background: #fff;
+}
+.secondary-button:hover:not(:disabled) {
+ border-color: #aaa;
+ background: var(--soft);
+}
+.primary-button:active:not(:disabled),
+.secondary-button:active:not(:disabled) {
+ transform: translateY(1px);
+}
+.primary-button:disabled,
+.secondary-button:disabled {
+ cursor: wait;
+ opacity: 0.6;
+}
+.primary-button svg {
+ width: 16px;
+ height: 16px;
+}
+
+.metric-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 16px;
+ margin-top: 40px;
+}
+.metric-card {
+ min-height: 142px;
+ padding: 24px;
+ border: 1px solid #e3e3e3;
+ border-radius: 8px;
+ background: #fff;
+}
+.metric-card p {
+ margin: 0 0 20px;
+ font-size: 13px;
+ color: var(--muted);
+}
+.metric-card strong {
+ display: block;
+ font-size: 32px;
+ font-weight: 500;
+ line-height: 1;
+ letter-spacing: -1px;
+}
+.metric-card span {
+ display: block;
+ margin-top: 10px;
+ font-size: 13px;
+ color: var(--faint);
+}
+
+.monitor-form-panel {
+ margin-top: 24px;
+ padding: 28px;
+ border: 1px solid #dcdcdc;
+ border-radius: 8px;
+ background: #fff;
+ box-shadow: 0 8px 28px rgb(29 74 54 / 0.06);
+}
+.form-panel-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 32px;
+ padding-bottom: 24px;
+ border-bottom: 1px solid #ededed;
+}
+.form-panel-heading h2 {
+ margin: 0;
+ font-size: 22px;
+ font-weight: 500;
+ letter-spacing: -0.4px;
+}
+.form-panel-heading > p {
+ max-width: 480px;
+ margin: 0;
+ font-size: 13px;
+ line-height: 1.55;
+ color: var(--muted);
+}
+.monitor-form {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 20px 16px;
+ margin-top: 24px;
+}
+.field {
+ display: grid;
+ gap: 8px;
+}
+.field-name {
+ grid-column: span 1;
+}
+.field-url {
+ grid-column: span 3;
+}
+.field > span {
+ font-size: 13px;
+ font-weight: 500;
+ color: #353535;
+}
+.field input,
+.field select {
+ width: 100%;
+ min-height: 42px;
+ padding: 8px 12px;
+ border: 1px solid #cfcfcf;
+ border-radius: 6px;
+ font-size: 14px;
+ color: var(--ink);
+ background: #fff;
+ box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.025);
+}
+.field input:hover,
+.field select:hover {
+ border-color: #aaa;
+}
+.field input:focus,
+.field select:focus {
+ border-color: var(--primary-deep);
+ outline: 0;
+ box-shadow: 0 0 0 3px rgb(36 180 126 / 0.14);
+}
+.toggle-field {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ align-self: end;
+ min-height: 42px;
+ font-size: 13px;
+ color: #444;
+ cursor: pointer;
+}
+.toggle-field input {
+ width: 16px;
+ height: 16px;
+ accent-color: var(--primary-deep);
+}
+.form-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ align-self: end;
+ grid-column: span 3;
+}
+.form-actions.compact-actions {
+ grid-column: span 2;
+}
+.form-error {
+ grid-column: 1 / -1;
+ margin: 0;
+ padding: 10px 12px;
+ border: 1px solid #efcaca;
+ border-radius: 6px;
+ font-size: 13px;
+ color: #9f2f2f;
+ background: #fff6f6;
+}
+
+.services-panel {
+ margin-top: 24px;
+ border: 1px solid #dedede;
+ border-radius: 8px;
+ overflow: hidden;
+ background: #fff;
+}
+.panel-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ min-height: 78px;
+ padding: 16px 20px;
+ border-bottom: 1px solid #e8e8e8;
+}
+.panel-heading h2 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 500;
+}
+.panel-heading p {
+ margin: 4px 0 0;
+ font-size: 13px;
+ color: var(--muted);
+}
+.icon-button {
+ display: grid;
+ place-items: center;
+ width: 34px;
+ height: 34px;
+ padding: 0;
+ border: 1px solid #d8d8d8;
+ border-radius: 6px;
+ color: #666;
+ background: #fff;
+ cursor: pointer;
+}
+.icon-button:hover:not(:disabled) {
+ color: #222;
+ border-color: #aaa;
+}
+.icon-button:disabled {
+ cursor: wait;
+ opacity: 0.6;
+}
+.icon-button svg {
+ width: 15px;
+ height: 15px;
+}
+.is-spinning {
+ animation: spin 900ms linear infinite;
+}
+.services-title,
+.service-row {
+ display: grid;
+ grid-template-columns: minmax(320px, 1.35fr) minmax(220px, 0.8fr) minmax(340px, 1fr);
+ align-items: center;
+ column-gap: 20px;
+}
+.services-title {
+ min-height: 40px;
+ padding: 0 20px;
+ border-bottom: 1px solid #e8e8e8;
+ font-size: 12px;
+ color: #888;
+ background: #fafafa;
+}
+.services-title span:last-child {
+ justify-self: start;
+}
+.service-row {
+ min-height: 112px;
+ padding: 18px 20px;
+ transition:
+ opacity 160ms ease,
+ background 160ms ease;
+}
+.service-row + .service-row {
+ border-top: 1px solid #ededed;
+}
+.service-row:hover {
+ background: #fdfdfd;
+}
+.service-row.is-disabled {
+ opacity: 0.55;
+ background: #fafafa;
+}
+.service-name {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ min-width: 0;
+}
+.service-name > div {
+ min-width: 0;
+}
+.service-icon {
+ display: grid;
+ place-items: center;
+ flex: 0 0 auto;
+ width: 40px;
+ height: 40px;
+ border: 1px solid #dedede;
+ border-radius: 6px;
+ color: #555;
+ background: #fafafa;
+}
+.service-icon svg {
+ width: 19px;
+ height: 19px;
+}
+.service-icon img {
+ width: 22px;
+ height: 22px;
+ border-radius: 4px;
+ object-fit: contain;
+}
+.service-name strong,
+.service-name small {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.service-name strong {
+ font-size: 15px;
+ font-weight: 500;
+}
+.monitor-name-link {
+ display: block;
+ max-width: 100%;
+ padding: 0;
+ overflow: hidden;
+ border: 0;
+ font-size: 15px;
+ font-weight: 500;
+ text-align: left;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ background: transparent;
+ cursor: pointer;
+}
+.monitor-name-link:hover {
+ color: #16885b;
+ text-decoration: underline;
+ text-underline-offset: 3px;
+}
+.service-name small {
+ margin-top: 4px;
+ font:
+ 400 12px/1.4 'IBM Plex Mono',
+ monospace;
+ color: #888;
+}
+.monitor-meta {
+ display: block;
+ margin-top: 5px;
+ font-size: 12px;
+ color: #aaa;
+}
+.monitor-result {
+ display: grid;
+ justify-items: start;
+ gap: 6px;
+ min-width: 0;
+}
+.monitor-result code {
+ font:
+ 400 12px/1.4 'IBM Plex Mono',
+ monospace;
+ color: #606060;
+}
+.monitor-result small {
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 12px;
+ color: #999;
+}
+.row-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ font-size: 12px;
+ font-weight: 500;
+ color: #777;
+}
+.row-status i {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #d05a5a;
+}
+.row-status.online {
+ color: #16885b;
+}
+.row-status.online i {
+ background: var(--primary-deep);
+ box-shadow: 0 0 0 3px rgb(62 207 142 / 0.12);
+}
+.row-status.offline {
+ color: #ae3d3d;
+}
+.row-status.checking {
+ color: #8b7722;
+}
+.row-status.checking i {
+ background: #d7bd53;
+ animation: blink 1.1s ease-in-out infinite;
+}
.row-actions {
- display: grid; grid-template-columns: auto auto repeat(3, 34px); justify-content: space-between; justify-self: stretch; width: 100%; gap: 4px;
+ display: grid;
+ grid-template-columns: auto auto repeat(3, 34px);
+ justify-content: space-between;
+ justify-self: stretch;
+ width: 100%;
+ gap: 4px;
}
.row-action {
- display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 0; min-height: 34px; padding: 6px 10px;
- border: 1px solid transparent; border-radius: 6px; font-size: 11px; font-weight: 500; line-height: 1; color: #626262; background: transparent;
- cursor: pointer; white-space: nowrap; transition: color 140ms ease, background 140ms ease, box-shadow 140ms ease, transform 140ms ease;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ min-width: 0;
+ min-height: 34px;
+ padding: 6px 10px;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ font-size: 11px;
+ font-weight: 500;
+ line-height: 1;
+ color: #626262;
+ background: transparent;
+ cursor: pointer;
+ white-space: nowrap;
+ transition:
+ color 140ms ease,
+ background 140ms ease,
+ box-shadow 140ms ease,
+ transform 140ms ease;
+}
+.row-action svg {
+ flex: 0 0 auto;
+ width: 14px;
+ height: 14px;
+ stroke-width: 1.8;
+}
+.row-action-icon {
+ width: 34px;
+ padding: 0;
+}
+.row-action:hover:not(:disabled) {
+ border-color: #e1e1e1;
+ color: #222;
+ background: #f8f8f8;
+}
+.row-action:active:not(:disabled) {
+ transform: translateY(1px);
+ box-shadow: none;
+}
+.row-action:disabled {
+ cursor: wait;
+ opacity: 0.48;
+}
+.row-action-accent {
+ color: #11764e;
+ background: #eef9f4;
+}
+.row-action-accent:hover:not(:disabled) {
+ border-color: #c9eadb;
+ color: #0d6542;
+ background: #e4f6ed;
+}
+.row-actions .danger-action {
+ color: #a33d3d;
+}
+.row-actions .danger-action:hover:not(:disabled) {
+ border-color: #efd1d1;
+ color: #922f2f;
+ background: #fff2f2;
}
-.row-action svg { flex: 0 0 auto; width: 14px; height: 14px; stroke-width: 1.8; }
-.row-action-icon { width: 34px; padding: 0; }
-.row-action:hover:not(:disabled) { border-color: #e1e1e1; color: #222; background: #f8f8f8; }
-.row-action:active:not(:disabled) { transform: translateY(1px); box-shadow: none; }
-.row-action:disabled { cursor: wait; opacity: 0.48; }
-.row-action-accent { color: #11764e; background: #eef9f4; }
-.row-action-accent:hover:not(:disabled) { border-color: #c9eadb; color: #0d6542; background: #e4f6ed; }
-.row-actions .danger-action { color: #a33d3d; }
-.row-actions .danger-action:hover:not(:disabled) { border-color: #efd1d1; color: #922f2f; background: #fff2f2; }
-.panel-state { display: grid; justify-items: center; padding: 72px 24px; text-align: center; }
-.panel-state strong { font-size: 16px; font-weight: 500; }
-.panel-state p { max-width: 420px; margin: 8px 0 20px; font-size: 13px; line-height: 1.55; color: var(--muted); }
-.empty-icon { display: grid; place-items: center; width: 48px; height: 48px; margin-bottom: 18px; border: 1px solid #dcdcdc; border-radius: 8px; color: #666; background: #fafafa; }
-.empty-icon svg { width: 22px; height: 22px; }
-.error-state { background: #fffafa; }
-.error-state strong { color: #8b3434; }
-.monitor-skeleton > div { display: grid; grid-template-columns: 40px 1fr 180px; align-items: center; gap: 16px; min-height: 104px; padding: 18px 20px; }
-.monitor-skeleton > div + div { border-top: 1px solid #ededed; }
-.monitor-skeleton i, .monitor-skeleton span, .monitor-skeleton b { display: block; border-radius: 5px; background: linear-gradient(90deg, #f1f1f1 20%, #f8f8f8 50%, #f1f1f1 80%); background-size: 220% 100%; animation: skeleton 1.4s ease-in-out infinite; }
-.monitor-skeleton i { width: 40px; height: 40px; }
-.monitor-skeleton span { width: min(360px, 80%); height: 32px; }
-.monitor-skeleton b { width: 180px; height: 32px; }
-.dashboard-page-footer { display: flex; justify-content: space-between; width: min(1280px, calc(100% - 48px)); margin: 0 auto; padding: 24px 0 32px; border-top: 1px solid #ededed; font-size: 12px; color: #999; }
+.panel-state {
+ display: grid;
+ justify-items: center;
+ padding: 72px 24px;
+ text-align: center;
+}
+.panel-state strong {
+ font-size: 16px;
+ font-weight: 500;
+}
+.panel-state p {
+ max-width: 420px;
+ margin: 8px 0 20px;
+ font-size: 13px;
+ line-height: 1.55;
+ color: var(--muted);
+}
+.empty-icon {
+ display: grid;
+ place-items: center;
+ width: 48px;
+ height: 48px;
+ margin-bottom: 18px;
+ border: 1px solid #dcdcdc;
+ border-radius: 8px;
+ color: #666;
+ background: #fafafa;
+}
+.empty-icon svg {
+ width: 22px;
+ height: 22px;
+}
+.error-state {
+ background: #fffafa;
+}
+.error-state strong {
+ color: #8b3434;
+}
+.monitor-skeleton > div {
+ display: grid;
+ grid-template-columns: 40px 1fr 180px;
+ align-items: center;
+ gap: 16px;
+ min-height: 104px;
+ padding: 18px 20px;
+}
+.monitor-skeleton > div + div {
+ border-top: 1px solid #ededed;
+}
+.monitor-skeleton i,
+.monitor-skeleton span,
+.monitor-skeleton b {
+ display: block;
+ border-radius: 5px;
+ background: linear-gradient(90deg, #f1f1f1 20%, #f8f8f8 50%, #f1f1f1 80%);
+ background-size: 220% 100%;
+ animation: skeleton 1.4s ease-in-out infinite;
+}
+.monitor-skeleton i {
+ width: 40px;
+ height: 40px;
+}
+.monitor-skeleton span {
+ width: min(360px, 80%);
+ height: 32px;
+}
+.monitor-skeleton b {
+ width: 180px;
+ height: 32px;
+}
+.dashboard-page-footer {
+ display: flex;
+ justify-content: space-between;
+ width: min(1280px, calc(100% - 48px));
+ margin: 0 auto;
+ padding: 24px 0 32px;
+ border-top: 1px solid #ededed;
+ font-size: 12px;
+ color: #999;
+}
-.auth-page { display: grid; grid-template-rows: auto 1fr auto; justify-items: center; min-height: 100dvh; padding: 32px 24px 24px; background: radial-gradient(circle at 50% 38%, rgb(62 207 142 / 0.08), transparent 34%), linear-gradient(#fff, #fcfcfc); }
-.auth-page::before { position: fixed; inset: 0; z-index: 0; pointer-events: none; background-image: radial-gradient(#dcdcdc 0.65px, transparent 0.65px); background-size: 16px 16px; mask-image: linear-gradient(to bottom, transparent, black 24%, transparent 76%); content: ""; }
-.auth-brand { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 8px; font-size: 20px; font-weight: 600; letter-spacing: -0.6px; }
-.auth-brand-mark { width: 26px; height: 26px; color: var(--primary-deep); }
-.auth-card { position: relative; z-index: 1; align-self: center; width: min(100%, 420px); padding: 36px; border: 1px solid var(--hairline); border-radius: 12px; background: rgb(255 255 255 / 0.96); box-shadow: 0 18px 55px rgb(24 74 52 / 0.08), 0 2px 8px rgb(0 0 0 / 0.04); animation: enter 500ms cubic-bezier(0.16, 1, 0.3, 1) both; }
-.auth-heading p { margin: 0 0 12px; font: 500 10px/1.4 "IBM Plex Mono", monospace; letter-spacing: 0.08em; text-transform: uppercase; color: #55816e; }
-.auth-heading h1 { margin: 0; font-size: 30px; font-weight: 500; line-height: 1.16; letter-spacing: -1.2px; }
-.auth-heading > span { display: block; margin-top: 12px; font-size: 14px; line-height: 1.55; color: #626262; }
-.auth-form { display: grid; gap: 20px; margin-top: 30px; }
-.auth-field { display: grid; gap: 8px; }
-.auth-field label { font-size: 13px; font-weight: 500; color: #353535; }
-.auth-field input { width: 100%; min-height: 42px; padding: 8px 12px; border: 1px solid #cfcfcf; border-radius: 6px; font-size: 14px; color: var(--ink); background: #fff; box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.025); }
-.auth-field input:hover { border-color: #a9a9a9; }
-.auth-field input:focus { border-color: var(--primary-deep); outline: 0; box-shadow: 0 0 0 3px rgb(36 180 126 / 0.14); }
-.auth-error { margin: -4px 0 0; padding: 10px 12px; border: 1px solid #efcaca; border-radius: 6px; font-size: 12px; line-height: 1.45; color: #9f2f2f; background: #fff6f6; }
-.auth-submit { display: inline-flex; align-items: center; justify-content: center; width: 100%; min-height: 42px; padding: 8px 16px; border: 1px solid #35c586; border-radius: 6px; font-size: 14px; font-weight: 600; color: var(--ink); background: var(--primary); box-shadow: 0 1px 2px rgb(0 0 0 / 0.08), inset 0 1px rgb(255 255 255 / 0.2); cursor: pointer; }
-.auth-submit:hover:not(:disabled) { background: #36c487; transform: translateY(-1px); }
-.auth-submit:active:not(:disabled) { background: var(--primary-deep); transform: translateY(1px); }
-.auth-submit:disabled { cursor: wait; opacity: 0.62; }
-.auth-footnote { position: relative; z-index: 1; margin: 0; font-size: 11px; color: #858585; }
+.auth-page {
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+ justify-items: center;
+ min-height: 100dvh;
+ padding: 32px 24px 24px;
+ background: radial-gradient(circle at 50% 38%, rgb(62 207 142 / 0.08), transparent 34%), linear-gradient(#fff, #fcfcfc);
+}
+.auth-page::before {
+ position: fixed;
+ inset: 0;
+ z-index: 0;
+ pointer-events: none;
+ background-image: radial-gradient(#dcdcdc 0.65px, transparent 0.65px);
+ background-size: 16px 16px;
+ mask-image: linear-gradient(to bottom, transparent, black 24%, transparent 76%);
+ content: '';
+}
+.auth-brand {
+ position: relative;
+ z-index: 1;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 20px;
+ font-weight: 600;
+ letter-spacing: -0.6px;
+}
+.auth-brand-mark {
+ width: 26px;
+ height: 26px;
+ color: var(--primary-deep);
+}
+.auth-card {
+ position: relative;
+ z-index: 1;
+ align-self: center;
+ width: min(100%, 420px);
+ padding: 36px;
+ border: 1px solid var(--hairline);
+ border-radius: 12px;
+ background: rgb(255 255 255 / 0.96);
+ box-shadow:
+ 0 18px 55px rgb(24 74 52 / 0.08),
+ 0 2px 8px rgb(0 0 0 / 0.04);
+ animation: enter 500ms cubic-bezier(0.16, 1, 0.3, 1) both;
+}
+.auth-heading p {
+ margin: 0 0 12px;
+ font:
+ 500 10px/1.4 'IBM Plex Mono',
+ monospace;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: #55816e;
+}
+.auth-heading h1 {
+ margin: 0;
+ font-size: 30px;
+ font-weight: 500;
+ line-height: 1.16;
+ letter-spacing: -1.2px;
+}
+.auth-heading > span {
+ display: block;
+ margin-top: 12px;
+ font-size: 14px;
+ line-height: 1.55;
+ color: #626262;
+}
+.auth-form {
+ display: grid;
+ gap: 20px;
+ margin-top: 30px;
+}
+.auth-field {
+ display: grid;
+ gap: 8px;
+}
+.auth-field label {
+ font-size: 13px;
+ font-weight: 500;
+ color: #353535;
+}
+.auth-field input {
+ width: 100%;
+ min-height: 42px;
+ padding: 8px 12px;
+ border: 1px solid #cfcfcf;
+ border-radius: 6px;
+ font-size: 14px;
+ color: var(--ink);
+ background: #fff;
+ box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.025);
+}
+.auth-field input:hover {
+ border-color: #a9a9a9;
+}
+.auth-field input:focus {
+ border-color: var(--primary-deep);
+ outline: 0;
+ box-shadow: 0 0 0 3px rgb(36 180 126 / 0.14);
+}
+.auth-error {
+ margin: -4px 0 0;
+ padding: 10px 12px;
+ border: 1px solid #efcaca;
+ border-radius: 6px;
+ font-size: 12px;
+ line-height: 1.45;
+ color: #9f2f2f;
+ background: #fff6f6;
+}
+.auth-submit {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ min-height: 42px;
+ padding: 8px 16px;
+ border: 1px solid #35c586;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--ink);
+ background: var(--primary);
+ box-shadow:
+ 0 1px 2px rgb(0 0 0 / 0.08),
+ inset 0 1px rgb(255 255 255 / 0.2);
+ cursor: pointer;
+}
+.auth-submit:hover:not(:disabled) {
+ background: #36c487;
+ transform: translateY(-1px);
+}
+.auth-submit:active:not(:disabled) {
+ background: var(--primary-deep);
+ transform: translateY(1px);
+}
+.auth-submit:disabled {
+ cursor: wait;
+ opacity: 0.62;
+}
+.auth-footnote {
+ position: relative;
+ z-index: 1;
+ margin: 0;
+ font-size: 11px;
+ color: #858585;
+}
-.detail-main { padding-top: 34px; }
-.back-link { display: inline-flex; align-items: center; gap: 7px; padding: 0; border: 0; font-size: 13px; color: var(--muted); background: transparent; cursor: pointer; }
-.back-link:hover { color: var(--ink); }
-.back-link svg { width: 15px; height: 15px; }
-.detail-hero { display: flex; align-items: flex-end; justify-content: space-between; gap: 32px; margin-top: 34px; padding-bottom: 36px; border-bottom: 1px solid #e7e7e7; }
-.detail-title { display: flex; align-items: center; gap: 18px; min-width: 0; }
-.status-orb { flex: 0 0 auto; width: 16px; height: 16px; border: 4px solid #f2d4d4; border-radius: 50%; background: #c74f4f; box-shadow: 0 0 0 8px #fff; }
-.status-orb.online { border-color: #c5f1df; background: var(--primary-deep); }
-.status-orb.checking { border-color: #eee5bd; background: #c6aa38; }
-.detail-title h1 { margin: 0; overflow: hidden; font-size: 40px; font-weight: 500; line-height: 1.1; letter-spacing: -1.5px; text-overflow: ellipsis; white-space: nowrap; }
-.detail-title a { display: inline-flex; align-items: center; gap: 6px; max-width: min(70vw, 680px); margin-top: 9px; overflow: hidden; font: 400 13px/1.4 "IBM Plex Mono", monospace; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
-.detail-title a:hover { color: var(--primary-deep); }
-.detail-title a svg { flex: 0 0 auto; width: 13px; height: 13px; }
-.detail-actions { display: flex; align-items: center; gap: 18px; }
-.muted-alert { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
-.muted-alert svg { width: 14px; height: 14px; }
-.sla-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-top: 24px; }
-.sla-card { min-width: 0; padding: 20px; border: 1px solid #e3e3e3; border-radius: 8px; background: #fff; }
-.sla-card p { margin: 0 0 17px; font-size: 12px; color: var(--muted); }
-.sla-card strong { display: block; font: 500 23px/1 "IBM Plex Mono", monospace; letter-spacing: -1px; }
-.sla-card span { display: block; margin-top: 11px; overflow: hidden; font-size: 11px; color: var(--faint); text-overflow: ellipsis; white-space: nowrap; }
-.sla-card.incident-summary { background: #fafafa; }
-.sla-card.incident-summary.has-incident { border-color: #e8c3c3; background: #fff8f8; }
-.sla-card.incident-summary.has-incident strong { color: #a83e3e; }
-.detail-grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(300px, 0.75fr); gap: 16px; margin-top: 16px; }
-.detail-grid.lower-grid { align-items: stretch; }
-.lower-grid > .data-panel { height: 100%; }
-.data-panel { overflow: hidden; border: 1px solid #dedede; border-radius: 8px; background: #fff; }
-.data-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 82px; padding: 18px 22px; border-bottom: 1px solid #ededed; }
-.data-panel-heading .overline { margin-bottom: 5px; }
-.data-panel-heading h2 { margin: 0; font-size: 18px; font-weight: 500; }
-.data-panel-heading > span { font: 400 11px/1.4 "IBM Plex Mono", monospace; color: var(--faint); }
-.chart-panel { min-height: 330px; }
-.sparkline-wrap { position: relative; height: 245px; padding: 26px 24px 24px; background: linear-gradient(180deg, #fdfefe, #fff); }
+.detail-main {
+ padding-top: 34px;
+}
+.back-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ padding: 0;
+ border: 0;
+ font-size: 13px;
+ color: var(--muted);
+ background: transparent;
+ cursor: pointer;
+}
+.back-link:hover {
+ color: var(--ink);
+}
+.back-link svg {
+ width: 15px;
+ height: 15px;
+}
+.detail-hero {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 32px;
+ margin-top: 34px;
+ padding-bottom: 36px;
+ border-bottom: 1px solid #e7e7e7;
+}
+.detail-title {
+ display: flex;
+ align-items: center;
+ gap: 18px;
+ min-width: 0;
+}
+.status-orb {
+ flex: 0 0 auto;
+ width: 16px;
+ height: 16px;
+ border: 4px solid #f2d4d4;
+ border-radius: 50%;
+ background: #c74f4f;
+ box-shadow: 0 0 0 8px #fff;
+}
+.status-orb.online {
+ border-color: #c5f1df;
+ background: var(--primary-deep);
+}
+.status-orb.checking {
+ border-color: #eee5bd;
+ background: #c6aa38;
+}
+.detail-title h1 {
+ margin: 0;
+ overflow: hidden;
+ font-size: 40px;
+ font-weight: 500;
+ line-height: 1.1;
+ letter-spacing: -1.5px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.detail-title a {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ max-width: min(70vw, 680px);
+ margin-top: 9px;
+ overflow: hidden;
+ font:
+ 400 13px/1.4 'IBM Plex Mono',
+ monospace;
+ color: var(--muted);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.detail-title a:hover {
+ color: var(--primary-deep);
+}
+.detail-title a svg {
+ flex: 0 0 auto;
+ width: 13px;
+ height: 13px;
+}
+.detail-actions {
+ display: flex;
+ align-items: center;
+ gap: 18px;
+}
+.muted-alert {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ color: var(--muted);
+}
+.muted-alert svg {
+ width: 14px;
+ height: 14px;
+}
+.sla-grid {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 12px;
+ margin-top: 24px;
+}
+.sla-card {
+ min-width: 0;
+ padding: 20px;
+ border: 1px solid #e3e3e3;
+ border-radius: 8px;
+ background: #fff;
+}
+.sla-card p {
+ margin: 0 0 17px;
+ font-size: 12px;
+ color: var(--muted);
+}
+.sla-card strong {
+ display: block;
+ font:
+ 500 23px/1 'IBM Plex Mono',
+ monospace;
+ letter-spacing: -1px;
+}
+.sla-card span {
+ display: block;
+ margin-top: 11px;
+ overflow: hidden;
+ font-size: 11px;
+ color: var(--faint);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.sla-card.incident-summary {
+ background: #fafafa;
+}
+.sla-card.incident-summary.has-incident {
+ border-color: #e8c3c3;
+ background: #fff8f8;
+}
+.sla-card.incident-summary.has-incident strong {
+ color: #a83e3e;
+}
+.detail-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.55fr) minmax(300px, 0.75fr);
+ gap: 16px;
+ margin-top: 16px;
+}
+.detail-grid.lower-grid {
+ align-items: stretch;
+}
+.lower-grid > .data-panel {
+ height: 100%;
+}
+.data-panel {
+ overflow: hidden;
+ border: 1px solid #dedede;
+ border-radius: 8px;
+ background: #fff;
+}
+.data-panel-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ min-height: 82px;
+ padding: 18px 22px;
+ border-bottom: 1px solid #ededed;
+}
+.data-panel-heading .overline {
+ margin-bottom: 5px;
+}
+.data-panel-heading h2 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 500;
+}
+.data-panel-heading > span {
+ font:
+ 400 11px/1.4 'IBM Plex Mono',
+ monospace;
+ color: var(--faint);
+}
+.chart-panel {
+ min-height: 330px;
+}
+.sparkline-wrap {
+ position: relative;
+ height: 245px;
+ padding: 26px 24px 24px;
+ background: linear-gradient(180deg, #fdfefe, #fff);
+}
.sparkline-wrap .recharts-wrapper:focus,
.sparkline-wrap .recharts-wrapper:focus-visible,
.sparkline-wrap .recharts-surface:focus,
@@ -218,250 +1079,1062 @@ button { color: inherit; }
.uptime-bar .recharts-wrapper:focus,
.uptime-bar .recharts-wrapper:focus-visible,
.uptime-bar .recharts-surface:focus,
-.uptime-bar .recharts-surface:focus-visible { outline: none; }
-.chart-scale { position: absolute; inset: 19px 22px 20px auto; display: flex; flex-direction: column; justify-content: space-between; pointer-events: none; font: 400 9px/1 "IBM Plex Mono", monospace; color: #aaa; }
-.chart-tooltip { display: grid; min-width: 144px; gap: 5px; padding: 10px 12px; border: 1px solid var(--hairline); border-radius: 6px; color: var(--ink); background: rgb(255 255 255 / 0.97); box-shadow: 0 8px 24px rgb(24 74 52 / 0.1), 0 2px 6px rgb(0 0 0 / 0.04); backdrop-filter: blur(8px); }
-.chart-tooltip time { font-size: 10px; line-height: 1.35; color: var(--muted); }
-.chart-tooltip strong { font: 500 15px/1.3 "IBM Plex Mono", monospace; letter-spacing: -0.3px; }
-.chart-tooltip > span { display: inline-flex; align-items: center; gap: 6px; width: max-content; font-size: 10px; font-weight: 500; color: #16885b; }
-.chart-tooltip > span::before { width: 6px; height: 6px; border-radius: 50%; background: var(--primary-deep); content: ""; }
-.chart-tooltip > span.is-down { color: #ae3d3d; }
-.chart-tooltip > span.is-down::before { background: #d95c5c; }
-.chart-tooltip-compact { min-width: 134px; }
-.chart-empty, .table-empty { display: grid; min-height: 150px; place-items: center; padding: 24px; font-size: 13px; color: var(--muted); text-align: center; }
-.uptime-panel { min-height: 330px; }
-.uptime-bar { height: 132px; padding: 28px 22px 14px; }
-.uptime-legend { display: flex; justify-content: space-between; padding: 14px 22px 0; border-top: 1px solid #f0f0f0; font-size: 10px; color: #999; }
-.uptime-legend span { display: inline-flex; align-items: center; gap: 5px; }
-.uptime-legend i { width: 6px; height: 6px; border-radius: 50%; background: var(--primary-deep); }
-.uptime-legend i.legend-down { margin-left: 5px; background: #d95c5c; }
-.data-table-wrap { overflow-x: auto; }
-.recent-checks-panel { display: flex; min-height: 0; flex-direction: column; }
-.recent-checks-scroll { max-height: clamp(320px, 52vh, 556px); overflow: auto; overscroll-behavior: contain; scrollbar-color: #b8c7c0 #f4f6f5; scrollbar-gutter: stable; scrollbar-width: thin; }
-.recent-checks-scroll:focus-visible { outline: 2px solid rgb(36 180 126 / 0.45); outline-offset: -2px; }
-.recent-checks-scroll::-webkit-scrollbar { width: 9px; height: 9px; }
-.recent-checks-scroll::-webkit-scrollbar-track { background: #f4f6f5; }
-.recent-checks-scroll::-webkit-scrollbar-thumb { border: 2px solid #f4f6f5; border-radius: 999px; background: #b8c7c0; }
-.recent-checks-scroll::-webkit-scrollbar-thumb:hover { background: #91a69d; }
-.recent-checks-scroll .data-table { min-width: 600px; }
-.recent-checks-scroll .data-table th { position: sticky; z-index: 1; top: 0; box-shadow: 0 1px #e5e5e5; }
-.data-table { width: 100%; border-collapse: collapse; font-size: 12px; }
-.data-table th { padding: 11px 16px; font-weight: 400; text-align: left; color: #888; background: #fafafa; }
-.data-table td { padding: 14px 16px; border-top: 1px solid #ededed; color: #555; white-space: nowrap; }
-.data-table code { max-width: 260px; overflow: hidden; font: 400 11px/1.4 "IBM Plex Mono", monospace; text-overflow: ellipsis; }
-.incident-list { max-height: 556px; overflow: auto; }
-.incident-list article { display: flex; gap: 13px; padding: 18px 20px; }
-.incident-list article + article { border-top: 1px solid #ededed; }
-.incident-list article > span { display: grid; flex: 0 0 auto; width: 30px; height: 30px; place-items: center; border-radius: 50%; color: #16885b; background: #eaf9f2; }
-.incident-list article.open > span { color: #aa4141; background: #fff0f0; }
-.incident-list article svg { width: 15px; height: 15px; }
-.incident-list strong { font-size: 13px; font-weight: 500; }
-.incident-list p { margin: 4px 0 6px; font-size: 12px; line-height: 1.45; color: var(--muted); }
-.incident-list small { font: 400 10px/1.4 "IBM Plex Mono", monospace; color: #aaa; }
-.detail-error { display: grid; min-height: 100dvh; place-content: center; justify-items: center; padding: 24px; text-align: center; }
-.detail-error p { color: var(--muted); }
+.uptime-bar .recharts-surface:focus-visible {
+ outline: none;
+}
+.chart-scale {
+ position: absolute;
+ inset: 19px 22px 20px auto;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ pointer-events: none;
+ font:
+ 400 9px/1 'IBM Plex Mono',
+ monospace;
+ color: #aaa;
+}
+.chart-tooltip {
+ display: grid;
+ min-width: 144px;
+ gap: 5px;
+ padding: 10px 12px;
+ border: 1px solid var(--hairline);
+ border-radius: 6px;
+ color: var(--ink);
+ background: rgb(255 255 255 / 0.97);
+ box-shadow:
+ 0 8px 24px rgb(24 74 52 / 0.1),
+ 0 2px 6px rgb(0 0 0 / 0.04);
+ backdrop-filter: blur(8px);
+}
+.chart-tooltip time {
+ font-size: 10px;
+ line-height: 1.35;
+ color: var(--muted);
+}
+.chart-tooltip strong {
+ font:
+ 500 15px/1.3 'IBM Plex Mono',
+ monospace;
+ letter-spacing: -0.3px;
+}
+.chart-tooltip > span {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ width: max-content;
+ font-size: 10px;
+ font-weight: 500;
+ color: #16885b;
+}
+.chart-tooltip > span::before {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--primary-deep);
+ content: '';
+}
+.chart-tooltip > span.is-down {
+ color: #ae3d3d;
+}
+.chart-tooltip > span.is-down::before {
+ background: #d95c5c;
+}
+.chart-tooltip-compact {
+ min-width: 134px;
+}
+.chart-empty,
+.table-empty {
+ display: grid;
+ min-height: 150px;
+ place-items: center;
+ padding: 24px;
+ font-size: 13px;
+ color: var(--muted);
+ text-align: center;
+}
+.uptime-panel {
+ min-height: 330px;
+}
+.uptime-bar {
+ height: 132px;
+ padding: 28px 22px 14px;
+}
+.uptime-legend {
+ display: flex;
+ justify-content: space-between;
+ padding: 14px 22px 0;
+ border-top: 1px solid #f0f0f0;
+ font-size: 10px;
+ color: #999;
+}
+.uptime-legend span {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+}
+.uptime-legend i {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--primary-deep);
+}
+.uptime-legend i.legend-down {
+ margin-left: 5px;
+ background: #d95c5c;
+}
+.data-table-wrap {
+ overflow-x: auto;
+}
+.recent-checks-panel {
+ display: flex;
+ min-height: 0;
+ flex-direction: column;
+}
+.recent-checks-scroll {
+ max-height: clamp(320px, 52vh, 556px);
+ overflow: auto;
+ overscroll-behavior: contain;
+ scrollbar-color: #b8c7c0 #f4f6f5;
+ scrollbar-gutter: stable;
+ scrollbar-width: thin;
+}
+.recent-checks-scroll:focus-visible {
+ outline: 2px solid rgb(36 180 126 / 0.45);
+ outline-offset: -2px;
+}
+.recent-checks-scroll::-webkit-scrollbar {
+ width: 9px;
+ height: 9px;
+}
+.recent-checks-scroll::-webkit-scrollbar-track {
+ background: #f4f6f5;
+}
+.recent-checks-scroll::-webkit-scrollbar-thumb {
+ border: 2px solid #f4f6f5;
+ border-radius: 999px;
+ background: #b8c7c0;
+}
+.recent-checks-scroll::-webkit-scrollbar-thumb:hover {
+ background: #91a69d;
+}
+.recent-checks-scroll .data-table {
+ min-width: 600px;
+}
+.recent-checks-scroll .data-table th {
+ position: sticky;
+ z-index: 1;
+ top: 0;
+ box-shadow: 0 1px #e5e5e5;
+}
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+}
+.data-table th {
+ padding: 11px 16px;
+ font-weight: 400;
+ text-align: left;
+ color: #888;
+ background: #fafafa;
+}
+.data-table td {
+ padding: 14px 16px;
+ border-top: 1px solid #ededed;
+ color: #555;
+ white-space: nowrap;
+}
+.data-table code {
+ max-width: 260px;
+ overflow: hidden;
+ font:
+ 400 11px/1.4 'IBM Plex Mono',
+ monospace;
+ text-overflow: ellipsis;
+}
+.incident-list {
+ max-height: 556px;
+ overflow: auto;
+}
+.incident-list article {
+ display: flex;
+ gap: 13px;
+ padding: 18px 20px;
+}
+.incident-list article + article {
+ border-top: 1px solid #ededed;
+}
+.incident-list article > span {
+ display: grid;
+ flex: 0 0 auto;
+ width: 30px;
+ height: 30px;
+ place-items: center;
+ border-radius: 50%;
+ color: #16885b;
+ background: #eaf9f2;
+}
+.incident-list article.open > span {
+ color: #aa4141;
+ background: #fff0f0;
+}
+.incident-list article svg {
+ width: 15px;
+ height: 15px;
+}
+.incident-list strong {
+ font-size: 13px;
+ font-weight: 500;
+}
+.incident-list p {
+ margin: 4px 0 6px;
+ font-size: 12px;
+ line-height: 1.45;
+ color: var(--muted);
+}
+.incident-list small {
+ font:
+ 400 10px/1.4 'IBM Plex Mono',
+ monospace;
+ color: #aaa;
+}
+.detail-error {
+ display: grid;
+ min-height: 100dvh;
+ place-content: center;
+ justify-items: center;
+ padding: 24px;
+ text-align: center;
+}
+.detail-error p {
+ color: var(--muted);
+}
-.settings-main { width: min(760px, calc(100% - 48px)); margin: 0 auto; padding: 34px 0 80px; }
-.settings-heading { margin-top: 42px; }
-.settings-heading h1 { margin: 0; font-size: 40px; font-weight: 500; letter-spacing: -1.5px; }
-.settings-heading > p:last-child { margin: 12px 0 0; color: var(--muted); }
-.settings-card { margin-top: 38px; overflow: hidden; border: 1px solid #dedede; border-radius: 10px; background: #fff; box-shadow: 0 8px 24px rgb(0 0 0 / .035); }
-.settings-card-intro { display: flex; gap: 16px; padding: 26px; border-bottom: 1px solid #ededed; }
-.settings-card-intro > span { display: grid; flex: 0 0 auto; width: 42px; height: 42px; place-items: center; border: 1px solid #bcead6; border-radius: 8px; color: #16885b; background: #f0fbf6; }
-.settings-card-intro svg { width: 19px; height: 19px; }
-.settings-card-intro h2 { margin: 0; font-size: 18px; font-weight: 500; }
-.settings-card-intro p { margin: 6px 0 0; font-size: 13px; line-height: 1.55; color: var(--muted); }
-.settings-form { display: grid; gap: 24px; padding: 26px; }
-.settings-toggle { display: flex; align-items: flex-start; gap: 11px; cursor: pointer; }
-.settings-toggle input { width: 17px; height: 17px; margin-top: 2px; accent-color: var(--primary-deep); }
-.settings-toggle strong, .settings-toggle small { display: block; }
-.settings-toggle strong { font-size: 13px; font-weight: 500; }
-.settings-toggle small { margin-top: 4px; color: var(--muted); }
-.settings-actions { display: flex; justify-content: flex-end; gap: 10px; padding-top: 20px; border-top: 1px solid #ededed; }
-.settings-actions svg { width: 14px; height: 14px; }
-.settings-success { margin: -8px 0 0; padding: 10px 12px; border: 1px solid #bde9d5; border-radius: 6px; font-size: 12px; color: #16754f; background: #f2fbf7; }
-.payload-preview { margin-top: 18px; padding: 24px 26px; border-radius: 9px; color: #fff; background: var(--night); }
-.payload-preview .overline { color: #83dcb4; }
-.payload-preview pre { margin: 16px 0 0; overflow-x: auto; font: 400 12px/1.7 "IBM Plex Mono", monospace; color: #d7d7d7; }
+.settings-main {
+ width: min(760px, calc(100% - 48px));
+ margin: 0 auto;
+ padding: 34px 0 80px;
+}
+.settings-heading {
+ margin-top: 42px;
+}
+.settings-heading h1 {
+ margin: 0;
+ font-size: 40px;
+ font-weight: 500;
+ letter-spacing: -1.5px;
+}
+.settings-heading > p:last-child {
+ margin: 12px 0 0;
+ color: var(--muted);
+}
+.settings-card {
+ margin-top: 38px;
+ overflow: hidden;
+ border: 1px solid #dedede;
+ border-radius: 10px;
+ background: #fff;
+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.035);
+}
+.settings-card-intro {
+ display: flex;
+ gap: 16px;
+ padding: 26px;
+ border-bottom: 1px solid #ededed;
+}
+.settings-card-intro > span {
+ display: grid;
+ flex: 0 0 auto;
+ width: 42px;
+ height: 42px;
+ place-items: center;
+ border: 1px solid #bcead6;
+ border-radius: 8px;
+ color: #16885b;
+ background: #f0fbf6;
+}
+.settings-card-intro svg {
+ width: 19px;
+ height: 19px;
+}
+.settings-card-intro h2 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 500;
+}
+.settings-card-intro p {
+ margin: 6px 0 0;
+ font-size: 13px;
+ line-height: 1.55;
+ color: var(--muted);
+}
+.settings-form {
+ display: grid;
+ gap: 24px;
+ padding: 26px;
+}
+.settings-toggle {
+ display: flex;
+ align-items: flex-start;
+ gap: 11px;
+ cursor: pointer;
+}
+.settings-toggle input {
+ width: 17px;
+ height: 17px;
+ margin-top: 2px;
+ accent-color: var(--primary-deep);
+}
+.settings-toggle strong,
+.settings-toggle small {
+ display: block;
+}
+.settings-toggle strong {
+ font-size: 13px;
+ font-weight: 500;
+}
+.settings-toggle small {
+ margin-top: 4px;
+ color: var(--muted);
+}
+.settings-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ padding-top: 20px;
+ border-top: 1px solid #ededed;
+}
+.settings-actions svg {
+ width: 14px;
+ height: 14px;
+}
+.settings-success {
+ margin: -8px 0 0;
+ padding: 10px 12px;
+ border: 1px solid #bde9d5;
+ border-radius: 6px;
+ font-size: 12px;
+ color: #16754f;
+ background: #f2fbf7;
+}
+.payload-preview {
+ margin-top: 18px;
+ padding: 24px 26px;
+ border-radius: 9px;
+ color: #fff;
+ background: var(--night);
+}
+.payload-preview .overline {
+ color: #83dcb4;
+}
+.payload-preview pre {
+ margin: 16px 0 0;
+ overflow-x: auto;
+ font:
+ 400 12px/1.7 'IBM Plex Mono',
+ monospace;
+ color: #d7d7d7;
+}
.status-page-shell {
min-height: 100dvh;
background:
- radial-gradient(circle at 12% 22%, rgb(62 207 142 / 0.07), transparent 28rem),
- linear-gradient(180deg, #fff 0, #fff 36%, #fafcfb 100%);
+ radial-gradient(circle at 12% 22%, rgb(62 207 142 / 0.07), transparent 28rem), linear-gradient(180deg, #fff 0, #fff 36%, #fafcfb 100%);
+}
+.status-header {
+ position: relative;
+}
+.status-header-inner {
+ width: min(960px, calc(100% - 48px));
}
-.status-header { position: relative; }
-.status-header-inner { width: min(960px, calc(100% - 48px)); }
.status-header-action {
- min-height: 34px; padding: 6px 13px; border: 1px solid #d5d5d5; border-radius: 6px;
- font-size: 12px; font-weight: 500; color: #444; background: rgb(255 255 255 / 0.86); cursor: pointer;
- transition: border-color 160ms ease, color 160ms ease, transform 160ms ease;
+ min-height: 34px;
+ padding: 6px 13px;
+ border: 1px solid #d5d5d5;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 500;
+ color: #444;
+ background: rgb(255 255 255 / 0.86);
+ cursor: pointer;
+ transition:
+ border-color 160ms ease,
+ color 160ms ease,
+ transform 160ms ease;
+}
+.status-header-action:hover {
+ border-color: #9bcdb7;
+ color: #16885b;
+ transform: translateY(-1px);
+}
+.status-main {
+ width: min(960px, calc(100% - 48px));
+ margin: 0 auto;
+ padding: 72px 0 56px;
+}
+.status-intro {
+ max-width: 620px;
+}
+.status-intro h1 {
+ margin: 0;
+ font-size: clamp(38px, 6vw, 56px);
+ font-weight: 500;
+ line-height: 1.02;
+ letter-spacing: -2.5px;
+}
+.status-intro > p:last-child {
+ max-width: 560px;
+ margin: 18px 0 0;
+ font-size: 16px;
+ line-height: 1.6;
+ color: var(--muted);
}
-.status-header-action:hover { border-color: #9bcdb7; color: #16885b; transform: translateY(-1px); }
-.status-main { width: min(960px, calc(100% - 48px)); margin: 0 auto; padding: 72px 0 56px; }
-.status-intro { max-width: 620px; }
-.status-intro h1 { margin: 0; font-size: clamp(38px, 6vw, 56px); font-weight: 500; line-height: 1.02; letter-spacing: -2.5px; }
-.status-intro > p:last-child { max-width: 560px; margin: 18px 0 0; font-size: 16px; line-height: 1.6; color: var(--muted); }
.status-banner {
- display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 18px; min-height: 116px;
- margin-top: 42px; padding: 24px 26px; border: 1px solid #b9e6d2; border-radius: 10px;
- color: #125c41; background: linear-gradient(110deg, #edfaf4, #f8fdfb); box-shadow: 0 12px 32px rgb(24 110 76 / 0.06);
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: 18px;
+ min-height: 116px;
+ margin-top: 42px;
+ padding: 24px 26px;
+ border: 1px solid #b9e6d2;
+ border-radius: 10px;
+ color: #125c41;
+ background: linear-gradient(110deg, #edfaf4, #f8fdfb);
+ box-shadow: 0 12px 32px rgb(24 110 76 / 0.06);
animation: enter 450ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
-.status-banner.degraded { border-color: #e7d796; color: #705d0f; background: linear-gradient(110deg, #fff9e8, #fffdf6); box-shadow: 0 12px 32px rgb(130 105 14 / 0.06); }
-.status-banner.down { border-color: #ebc1c1; color: #8e3030; background: linear-gradient(110deg, #fff2f2, #fffafa); box-shadow: 0 12px 32px rgb(130 34 34 / 0.06); }
-.status-banner-icon { display: grid; width: 46px; height: 46px; place-items: center; border: 1px solid rgb(36 180 126 / 0.22); border-radius: 50%; background: rgb(255 255 255 / 0.72); }
-.status-banner-icon svg { width: 21px; height: 21px; }
-.status-banner.degraded .status-banner-icon { border-color: rgb(178 145 24 / 0.25); }
-.status-banner.down .status-banner-icon { border-color: rgb(174 61 61 / 0.22); }
-.status-banner h2 { margin: 0; font-size: 18px; font-weight: 600; letter-spacing: -0.3px; }
-.status-banner p { margin: 6px 0 0; font-size: 13px; color: currentColor; opacity: 0.75; }
-.status-banner time { justify-self: end; font: 400 10px/1.4 "IBM Plex Mono", monospace; opacity: 0.66; white-space: nowrap; }
-.public-services-panel { margin-top: 18px; overflow: hidden; border: 1px solid #dedede; border-radius: 10px; background: #fff; box-shadow: 0 14px 40px rgb(28 65 49 / 0.04); animation: enter 500ms 60ms cubic-bezier(0.16, 1, 0.3, 1) both; }
-.public-services-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; min-height: 88px; padding: 18px 22px; border-bottom: 1px solid #e8e8e8; }
-.public-services-heading h2 { margin: 0; font-size: 18px; font-weight: 500; }
-.public-services-heading p { margin: 5px 0 0; font-size: 12px; color: var(--muted); }
-.public-service-row { display: grid; grid-template-columns: minmax(190px, 0.7fr) 120px minmax(320px, 1.2fr); align-items: center; gap: 24px; min-height: 128px; padding: 22px; }
-.public-service-row + .public-service-row { border-top: 1px solid #ededed; }
-.public-service-row:hover { background: #fdfefd; }
-.public-service-summary { display: flex; align-items: center; gap: 14px; min-width: 0; }
-.public-service-summary > div { min-width: 0; }
-.public-service-summary strong { display: block; overflow: hidden; font-size: 15px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
-.public-service-summary .row-status { margin-top: 7px; }
-.public-service-uptime { text-align: right; }
-.public-service-uptime span { display: block; font-size: 10px; color: var(--faint); }
-.public-service-uptime strong { display: block; margin-top: 5px; font: 500 17px/1.2 "IBM Plex Mono", monospace; letter-spacing: -0.5px; }
-.status-history { min-width: 0; }
-.uptime-days { display: flex; align-items: stretch; width: 100%; height: 32px; gap: 2px; }
-.uptime-day { flex: 1 1 0; min-width: 1px; border-radius: 2px; background: #e6e8e7; transition: filter 140ms ease, transform 140ms ease; }
-.uptime-day:hover { z-index: 1; filter: saturate(1.15) brightness(0.92); transform: scaleY(1.12); }
-.uptime-day.is-up { background: #3ecf8e; }
-.uptime-day.is-partial { background: #e1bc45; }
-.uptime-day.is-down { background: #dc6262; }
-.uptime-day.is-empty { background: #e8ebe9; }
-.uptime-days-caption { display: flex; align-items: center; gap: 9px; margin-top: 8px; font: 400 9px/1.3 "IBM Plex Mono", monospace; color: #a0a0a0; }
-.uptime-days-caption i { flex: 1; height: 1px; background: #ededed; }
-.status-panel-state { display: grid; justify-items: center; min-height: 280px; place-content: center; padding: 48px 24px; text-align: center; }
-.status-panel-state > span { display: grid; width: 44px; height: 44px; margin-bottom: 16px; place-items: center; border: 1px solid #dcdcdc; border-radius: 50%; color: #666; background: #fafafa; }
-.status-panel-state svg { width: 19px; height: 19px; }
-.status-panel-state strong { font-size: 15px; font-weight: 500; }
-.status-panel-state p { max-width: 400px; margin: 8px 0 18px; font-size: 13px; line-height: 1.5; color: var(--muted); }
-.status-error-state { margin-top: 42px; border: 1px solid #ebd1d1; border-radius: 10px; background: #fffafa; }
-.status-error-state > span { border-color: #ebcaca; color: #a34242; background: #fff3f3; }
-.status-loading { margin-top: 42px; }
-.status-banner-skeleton { height: 116px; border-radius: 10px; background: linear-gradient(90deg, #eef2f0 20%, #f8faf9 50%, #eef2f0 80%); background-size: 220% 100%; animation: skeleton 1.4s ease-in-out infinite; }
-.status-service-skeleton { margin-top: 18px; overflow: hidden; border: 1px solid #e4e4e4; border-radius: 10px; background: #fff; }
-.status-service-skeleton > div { display: grid; grid-template-columns: 42px 1fr minmax(240px, 0.8fr); align-items: center; gap: 16px; min-height: 116px; padding: 22px; }
-.status-service-skeleton > div + div { border-top: 1px solid #ededed; }
-.status-service-skeleton i, .status-service-skeleton span, .status-service-skeleton b { display: block; border-radius: 5px; background: linear-gradient(90deg, #f0f2f1 20%, #fafafa 50%, #f0f2f1 80%); background-size: 220% 100%; animation: skeleton 1.4s ease-in-out infinite; }
-.status-service-skeleton i { width: 42px; height: 42px; }
-.status-service-skeleton span { width: min(220px, 75%); height: 34px; }
-.status-service-skeleton b { height: 32px; }
-.status-footer { display: flex; justify-content: space-between; width: min(960px, calc(100% - 48px)); margin: 0 auto; padding: 28px 0 36px; border-top: 1px solid #e7ebe9; font-size: 11px; color: #939393; }
-.status-footer span:last-child { display: inline-flex; align-items: center; gap: 7px; }
-.status-footer i { width: 6px; height: 6px; border-radius: 50%; background: var(--primary-deep); box-shadow: 0 0 0 3px rgb(62 207 142 / 0.12); }
+.status-banner.degraded {
+ border-color: #e7d796;
+ color: #705d0f;
+ background: linear-gradient(110deg, #fff9e8, #fffdf6);
+ box-shadow: 0 12px 32px rgb(130 105 14 / 0.06);
+}
+.status-banner.down {
+ border-color: #ebc1c1;
+ color: #8e3030;
+ background: linear-gradient(110deg, #fff2f2, #fffafa);
+ box-shadow: 0 12px 32px rgb(130 34 34 / 0.06);
+}
+.status-banner-icon {
+ display: grid;
+ width: 46px;
+ height: 46px;
+ place-items: center;
+ border: 1px solid rgb(36 180 126 / 0.22);
+ border-radius: 50%;
+ background: rgb(255 255 255 / 0.72);
+}
+.status-banner-icon svg {
+ width: 21px;
+ height: 21px;
+}
+.status-banner.degraded .status-banner-icon {
+ border-color: rgb(178 145 24 / 0.25);
+}
+.status-banner.down .status-banner-icon {
+ border-color: rgb(174 61 61 / 0.22);
+}
+.status-banner h2 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ letter-spacing: -0.3px;
+}
+.status-banner p {
+ margin: 6px 0 0;
+ font-size: 13px;
+ color: currentColor;
+ opacity: 0.75;
+}
+.status-banner time {
+ justify-self: end;
+ font:
+ 400 10px/1.4 'IBM Plex Mono',
+ monospace;
+ opacity: 0.66;
+ white-space: nowrap;
+}
+.public-services-panel {
+ margin-top: 18px;
+ overflow: hidden;
+ border: 1px solid #dedede;
+ border-radius: 10px;
+ background: #fff;
+ box-shadow: 0 14px 40px rgb(28 65 49 / 0.04);
+ animation: enter 500ms 60ms cubic-bezier(0.16, 1, 0.3, 1) both;
+}
+.public-services-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ min-height: 88px;
+ padding: 18px 22px;
+ border-bottom: 1px solid #e8e8e8;
+}
+.public-services-heading h2 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 500;
+}
+.public-services-heading p {
+ margin: 5px 0 0;
+ font-size: 12px;
+ color: var(--muted);
+}
+.public-service-row {
+ display: grid;
+ grid-template-columns: minmax(190px, 0.7fr) 120px minmax(320px, 1.2fr);
+ align-items: center;
+ gap: 24px;
+ min-height: 128px;
+ padding: 22px;
+}
+.public-service-row + .public-service-row {
+ border-top: 1px solid #ededed;
+}
+.public-service-row:hover {
+ background: #fdfefd;
+}
+.public-service-summary {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ min-width: 0;
+}
+.public-service-summary > div {
+ min-width: 0;
+}
+.public-service-summary strong {
+ display: block;
+ overflow: hidden;
+ font-size: 15px;
+ font-weight: 500;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.public-service-summary .row-status {
+ margin-top: 7px;
+}
+.public-service-uptime {
+ text-align: right;
+}
+.public-service-uptime span {
+ display: block;
+ font-size: 10px;
+ color: var(--faint);
+}
+.public-service-uptime strong {
+ display: block;
+ margin-top: 5px;
+ font:
+ 500 17px/1.2 'IBM Plex Mono',
+ monospace;
+ letter-spacing: -0.5px;
+}
+.status-history {
+ min-width: 0;
+}
+.uptime-days {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+ height: 32px;
+ gap: 2px;
+}
+.uptime-day {
+ flex: 1 1 0;
+ min-width: 1px;
+ border-radius: 2px;
+ background: #e6e8e7;
+ transition:
+ filter 140ms ease,
+ transform 140ms ease;
+}
+.uptime-day:hover {
+ z-index: 1;
+ filter: saturate(1.15) brightness(0.92);
+ transform: scaleY(1.12);
+}
+.uptime-day.is-up {
+ background: #3ecf8e;
+}
+.uptime-day.is-partial {
+ background: #e1bc45;
+}
+.uptime-day.is-down {
+ background: #dc6262;
+}
+.uptime-day.is-empty {
+ background: #e8ebe9;
+}
+.uptime-days-caption {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ margin-top: 8px;
+ font:
+ 400 9px/1.3 'IBM Plex Mono',
+ monospace;
+ color: #a0a0a0;
+}
+.uptime-days-caption i {
+ flex: 1;
+ height: 1px;
+ background: #ededed;
+}
+.status-panel-state {
+ display: grid;
+ justify-items: center;
+ min-height: 280px;
+ place-content: center;
+ padding: 48px 24px;
+ text-align: center;
+}
+.status-panel-state > span {
+ display: grid;
+ width: 44px;
+ height: 44px;
+ margin-bottom: 16px;
+ place-items: center;
+ border: 1px solid #dcdcdc;
+ border-radius: 50%;
+ color: #666;
+ background: #fafafa;
+}
+.status-panel-state svg {
+ width: 19px;
+ height: 19px;
+}
+.status-panel-state strong {
+ font-size: 15px;
+ font-weight: 500;
+}
+.status-panel-state p {
+ max-width: 400px;
+ margin: 8px 0 18px;
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--muted);
+}
+.status-error-state {
+ margin-top: 42px;
+ border: 1px solid #ebd1d1;
+ border-radius: 10px;
+ background: #fffafa;
+}
+.status-error-state > span {
+ border-color: #ebcaca;
+ color: #a34242;
+ background: #fff3f3;
+}
+.status-loading {
+ margin-top: 42px;
+}
+.status-banner-skeleton {
+ height: 116px;
+ border-radius: 10px;
+ background: linear-gradient(90deg, #eef2f0 20%, #f8faf9 50%, #eef2f0 80%);
+ background-size: 220% 100%;
+ animation: skeleton 1.4s ease-in-out infinite;
+}
+.status-service-skeleton {
+ margin-top: 18px;
+ overflow: hidden;
+ border: 1px solid #e4e4e4;
+ border-radius: 10px;
+ background: #fff;
+}
+.status-service-skeleton > div {
+ display: grid;
+ grid-template-columns: 42px 1fr minmax(240px, 0.8fr);
+ align-items: center;
+ gap: 16px;
+ min-height: 116px;
+ padding: 22px;
+}
+.status-service-skeleton > div + div {
+ border-top: 1px solid #ededed;
+}
+.status-service-skeleton i,
+.status-service-skeleton span,
+.status-service-skeleton b {
+ display: block;
+ border-radius: 5px;
+ background: linear-gradient(90deg, #f0f2f1 20%, #fafafa 50%, #f0f2f1 80%);
+ background-size: 220% 100%;
+ animation: skeleton 1.4s ease-in-out infinite;
+}
+.status-service-skeleton i {
+ width: 42px;
+ height: 42px;
+}
+.status-service-skeleton span {
+ width: min(220px, 75%);
+ height: 34px;
+}
+.status-service-skeleton b {
+ height: 32px;
+}
+.status-footer {
+ display: flex;
+ justify-content: space-between;
+ width: min(960px, calc(100% - 48px));
+ margin: 0 auto;
+ padding: 28px 0 36px;
+ border-top: 1px solid #e7ebe9;
+ font-size: 11px;
+ color: #939393;
+}
+.status-footer span:last-child {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+}
+.status-footer i {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--primary-deep);
+ box-shadow: 0 0 0 3px rgb(62 207 142 / 0.12);
+}
-@keyframes spin { to { transform: rotate(360deg); } }
-@keyframes blink { 50% { opacity: 0.35; } }
-@keyframes loading-pulse { 50% { opacity: 0.4; transform: scale(0.94); } }
-@keyframes skeleton { to { background-position: -220% 0; } }
-@keyframes enter { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: translateY(0); } }
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+@keyframes blink {
+ 50% {
+ opacity: 0.35;
+ }
+}
+@keyframes loading-pulse {
+ 50% {
+ opacity: 0.4;
+ transform: scale(0.94);
+ }
+}
+@keyframes skeleton {
+ to {
+ background-position: -220% 0;
+ }
+}
+@keyframes enter {
+ from {
+ opacity: 0;
+ transform: translateY(18px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
@media (max-width: 1000px) {
- .monitor-form { grid-template-columns: repeat(2, 1fr); }
- .field-name, .field-url { grid-column: span 1; }
- .form-actions { grid-column: span 1; }
- .form-actions.compact-actions { grid-column: span 2; }
- .services-title { display: none; }
- .service-row { grid-template-columns: minmax(280px, 1fr) minmax(200px, 0.7fr); }
+ .monitor-form {
+ grid-template-columns: repeat(2, 1fr);
+ }
+ .field-name,
+ .field-url {
+ grid-column: span 1;
+ }
+ .form-actions {
+ grid-column: span 1;
+ }
+ .form-actions.compact-actions {
+ grid-column: span 2;
+ }
+ .services-title {
+ display: none;
+ }
+ .service-row {
+ grid-template-columns: minmax(280px, 1fr) minmax(200px, 0.7fr);
+ }
.row-actions {
- display: flex; grid-column: 1 / -1; align-items: center; justify-content: flex-end; justify-self: stretch; gap: 4px;
- width: calc(100% + 40px); max-width: none; margin: 4px -20px -18px; padding: 12px 20px 12px 74px;
- border-top: 1px solid #ededed; background: linear-gradient(90deg, #fcfcfc, #fff);
+ display: flex;
+ grid-column: 1 / -1;
+ align-items: center;
+ justify-content: flex-end;
+ justify-self: stretch;
+ gap: 4px;
+ width: calc(100% + 40px);
+ max-width: none;
+ margin: 4px -20px -18px;
+ padding: 12px 20px 12px 74px;
+ border-top: 1px solid #ededed;
+ background: linear-gradient(90deg, #fcfcfc, #fff);
+ }
+ .row-actions::before {
+ margin-right: auto;
+ font-size: 11px;
+ font-weight: 500;
+ color: #999;
+ content: 'Actions';
+ }
+ .sla-grid {
+ grid-template-columns: repeat(3, 1fr);
+ }
+ .detail-grid,
+ .detail-grid.lower-grid {
+ grid-template-columns: 1fr;
}
- .row-actions::before { margin-right: auto; font-size: 11px; font-weight: 500; color: #999; content: "Actions"; }
- .sla-grid { grid-template-columns: repeat(3, 1fr); }
- .detail-grid, .detail-grid.lower-grid { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
- .dashboard-header-inner, .dashboard-main, .dashboard-page-footer { width: min(100% - 32px, 1280px); }
- .status-header-inner, .status-main, .status-footer { width: min(100% - 32px, 960px); }
- .header-context { display: none; }
- .dashboard-main { padding: 40px 0 56px; }
- .dashboard-intro { align-items: flex-start; flex-direction: column; }
- .dashboard-intro h1 { font-size: 34px; }
- .metric-grid { grid-template-columns: 1fr; gap: 10px; margin-top: 28px; }
- .metric-card { min-height: auto; padding: 20px; }
- .metric-card p { margin-bottom: 12px; }
- .monitor-form-panel { padding: 20px; }
- .form-panel-heading { flex-direction: column; gap: 12px; }
- .monitor-form { grid-template-columns: 1fr; }
- .field-name, .field-url, .form-actions { grid-column: auto; }
- .form-actions { justify-content: stretch; }
- .form-actions.compact-actions { grid-column: auto; }
- .form-actions button { flex: 1; }
- .service-row { grid-template-columns: 1fr; gap: 18px; }
- .row-actions { grid-column: auto; }
- .dashboard-page-footer { flex-direction: column; gap: 8px; }
- .detail-hero { align-items: flex-start; flex-direction: column; }
- .detail-actions { flex-wrap: wrap; }
- .detail-title h1 { font-size: 34px; }
- .sla-grid { grid-template-columns: repeat(2, 1fr); }
- .sla-card.incident-summary { grid-column: 1 / -1; }
- .settings-main { width: min(100% - 32px, 760px); }
- .status-main { padding: 52px 0 44px; }
- .status-banner { grid-template-columns: auto 1fr; }
- .status-banner time { grid-column: 2; justify-self: start; }
- .public-service-row { grid-template-columns: 1fr auto; gap: 20px; }
- .status-history { grid-column: 1 / -1; }
+ .dashboard-header-inner,
+ .dashboard-main,
+ .dashboard-page-footer {
+ width: min(100% - 32px, 1280px);
+ }
+ .status-header-inner,
+ .status-main,
+ .status-footer {
+ width: min(100% - 32px, 960px);
+ }
+ .header-context {
+ display: none;
+ }
+ .dashboard-main {
+ padding: 40px 0 56px;
+ }
+ .dashboard-intro {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .dashboard-intro h1 {
+ font-size: 34px;
+ }
+ .metric-grid {
+ grid-template-columns: 1fr;
+ gap: 10px;
+ margin-top: 28px;
+ }
+ .metric-card {
+ min-height: auto;
+ padding: 20px;
+ }
+ .metric-card p {
+ margin-bottom: 12px;
+ }
+ .monitor-form-panel {
+ padding: 20px;
+ }
+ .form-panel-heading {
+ flex-direction: column;
+ gap: 12px;
+ }
+ .monitor-form {
+ grid-template-columns: 1fr;
+ }
+ .field-name,
+ .field-url,
+ .form-actions {
+ grid-column: auto;
+ }
+ .form-actions {
+ justify-content: stretch;
+ }
+ .form-actions.compact-actions {
+ grid-column: auto;
+ }
+ .form-actions button {
+ flex: 1;
+ }
+ .service-row {
+ grid-template-columns: 1fr;
+ gap: 18px;
+ }
+ .row-actions {
+ grid-column: auto;
+ }
+ .dashboard-page-footer {
+ flex-direction: column;
+ gap: 8px;
+ }
+ .detail-hero {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .detail-actions {
+ flex-wrap: wrap;
+ }
+ .detail-title h1 {
+ font-size: 34px;
+ }
+ .sla-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+ .sla-card.incident-summary {
+ grid-column: 1 / -1;
+ }
+ .settings-main {
+ width: min(100% - 32px, 760px);
+ }
+ .status-main {
+ padding: 52px 0 44px;
+ }
+ .status-banner {
+ grid-template-columns: auto 1fr;
+ }
+ .status-banner time {
+ grid-column: 2;
+ justify-self: start;
+ }
+ .public-service-row {
+ grid-template-columns: 1fr auto;
+ gap: 20px;
+ }
+ .status-history {
+ grid-column: 1 / -1;
+ }
}
@media (max-width: 520px) {
- .auth-page { padding: 24px 16px; }
- .auth-card { padding: 28px 22px; }
- .auth-heading h1 { font-size: 27px; }
- .dashboard-header-inner { height: 62px; }
- .status-header-action { min-height: 36px; padding: 6px 14px; }
- .public-services-heading .icon-button { flex: 0 0 44px; width: 44px; height: 44px; }
- .dashboard-main { padding-top: 32px; }
- .dashboard-intro > div > p:last-child { font-size: 14px; line-height: 1.5; }
- .panel-heading { min-height: 70px; padding: 14px 16px; }
- .service-row { padding: 18px 16px; }
+ .auth-page {
+ padding: 24px 16px;
+ }
+ .auth-card {
+ padding: 28px 22px;
+ }
+ .auth-heading h1 {
+ font-size: 27px;
+ }
+ .dashboard-header-inner {
+ height: 62px;
+ }
+ .status-header-action {
+ min-height: 36px;
+ padding: 6px 14px;
+ }
+ .public-services-heading .icon-button {
+ flex: 0 0 44px;
+ width: 44px;
+ height: 44px;
+ }
+ .dashboard-main {
+ padding-top: 32px;
+ }
+ .dashboard-intro > div > p:last-child {
+ font-size: 14px;
+ line-height: 1.5;
+ }
+ .panel-heading {
+ min-height: 70px;
+ padding: 14px 16px;
+ }
+ .service-row {
+ padding: 18px 16px;
+ }
.row-actions {
- display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) repeat(3, 40px); justify-content: space-between; justify-self: stretch;
- width: 100%; max-width: none; margin: 0; padding: 3px;
- border: 1px solid #dedede; border-radius: 9px; background: #f8f8f8; box-shadow: 0 1px 2px rgb(0 0 0 / 0.035);
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) repeat(3, 40px);
+ justify-content: space-between;
+ justify-self: stretch;
+ width: 100%;
+ max-width: none;
+ margin: 0;
+ padding: 3px;
+ border: 1px solid #dedede;
+ border-radius: 9px;
+ background: #f8f8f8;
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.035);
+ }
+ .row-actions::before {
+ display: none;
+ }
+ .row-action {
+ min-height: 40px;
+ }
+ .row-action-icon {
+ width: 40px;
+ }
+ .nav-actions {
+ gap: 12px;
+ }
+ .detail-title {
+ align-items: flex-start;
+ }
+ .status-orb {
+ margin-top: 11px;
+ }
+ .sla-grid {
+ grid-template-columns: 1fr;
+ }
+ .sla-card.incident-summary {
+ grid-column: auto;
+ }
+ .data-panel-heading {
+ padding: 16px;
+ }
+ .settings-heading h1 {
+ font-size: 34px;
+ }
+ .settings-card-intro,
+ .settings-form {
+ padding: 20px;
+ }
+ .settings-actions {
+ align-items: stretch;
+ flex-direction: column-reverse;
+ }
+ .status-intro h1 {
+ letter-spacing: -1.8px;
+ }
+ .status-intro > p:last-child {
+ font-size: 14px;
+ }
+ .status-banner {
+ grid-template-columns: 1fr;
+ gap: 12px;
+ padding: 22px 20px;
+ }
+ .status-banner-icon {
+ width: 40px;
+ height: 40px;
+ }
+ .status-banner time {
+ grid-column: auto;
+ }
+ .public-services-heading {
+ padding: 17px 16px;
+ }
+ .public-services-heading p {
+ max-width: 250px;
+ }
+ .public-service-row {
+ padding: 20px 16px;
+ }
+ .public-service-uptime strong {
+ font-size: 15px;
+ }
+ .uptime-days {
+ height: 28px;
+ gap: 1px;
+ }
+ .status-footer {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 8px;
}
- .row-actions::before { display: none; }
- .row-action { min-height: 40px; }
- .row-action-icon { width: 40px; }
- .nav-actions { gap: 12px; }
- .detail-title { align-items: flex-start; }
- .status-orb { margin-top: 11px; }
- .sla-grid { grid-template-columns: 1fr; }
- .sla-card.incident-summary { grid-column: auto; }
- .data-panel-heading { padding: 16px; }
- .settings-heading h1 { font-size: 34px; }
- .settings-card-intro, .settings-form { padding: 20px; }
- .settings-actions { align-items: stretch; flex-direction: column-reverse; }
- .status-intro h1 { letter-spacing: -1.8px; }
- .status-intro > p:last-child { font-size: 14px; }
- .status-banner { grid-template-columns: 1fr; gap: 12px; padding: 22px 20px; }
- .status-banner-icon { width: 40px; height: 40px; }
- .status-banner time { grid-column: auto; }
- .public-services-heading { padding: 17px 16px; }
- .public-services-heading p { max-width: 250px; }
- .public-service-row { padding: 20px 16px; }
- .public-service-uptime strong { font-size: 15px; }
- .uptime-days { height: 28px; gap: 1px; }
- .status-footer { align-items: flex-start; flex-direction: column; gap: 8px; }
}
@media (max-width: 380px) {
- .row-action-labeled svg { display: none; }
+ .row-action-labeled svg {
+ display: none;
+ }
}
@media (prefers-reduced-motion: reduce) {
- *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
}
diff --git a/src/worker/checks/persist-result.ts b/src/worker/checks/persist-result.ts
index f9bd975..7aa9361 100644
--- a/src/worker/checks/persist-result.ts
+++ b/src/worker/checks/persist-result.ts
@@ -1,18 +1,13 @@
-import { and, eq, isNull, sql } from "drizzle-orm";
-import type { Database } from "../db/client";
-import { checks, incidents, monitors } from "../db/schema";
-import type { CheckResult, Monitor } from "./run-check";
+import { and, eq, isNull, sql } from 'drizzle-orm';
+import type { Database } from '../db/client';
+import { checks, incidents, monitors } from '../db/schema';
+import type { CheckResult, Monitor } from './run-check';
-export type IncidentTransition = "opened" | "resolved" | null;
+export type IncidentTransition = 'opened' | 'resolved' | null;
-type BatchStatement = Parameters[0][number];
+type BatchStatement = Parameters[0][number];
-export function buildResultStatements(
- db: Database,
- monitor: Monitor,
- result: CheckResult,
- checkedAt: Date,
-) {
+export function buildResultStatements(db: Database, monitor: Monitor, result: CheckResult, checkedAt: Date) {
const statements: BatchStatement[] = [
db.insert(checks).values({
monitorId: monitor.id,
@@ -47,7 +42,7 @@ export function buildResultStatements(
updatedAt: checkedAt,
}),
);
- transition = "opened";
+ transition = 'opened';
} else if (monitor.lastOk === false && result.ok) {
statements.push(
db
@@ -57,14 +52,9 @@ export function buildResultStatements(
durationMs: sql`${checkedAt.getTime()} - ${incidents.startedAt}`,
updatedAt: checkedAt,
})
- .where(
- and(
- eq(incidents.monitorId, monitor.id),
- isNull(incidents.resolvedAt),
- ),
- ),
+ .where(and(eq(incidents.monitorId, monitor.id), isNull(incidents.resolvedAt))),
);
- transition = "resolved";
+ transition = 'resolved';
}
return { statements, transition };
diff --git a/src/worker/checks/run-check.ts b/src/worker/checks/run-check.ts
index dd91852..cd9cf7e 100644
--- a/src/worker/checks/run-check.ts
+++ b/src/worker/checks/run-check.ts
@@ -1,4 +1,4 @@
-import type { monitors } from "../db/schema";
+import type { monitors } from '../db/schema';
export type Monitor = typeof monitors.$inferSelect;
@@ -14,9 +14,9 @@ export async function runCheck(monitor: Monitor): Promise {
try {
const response = await fetch(monitor.url, {
method: monitor.method,
- redirect: "follow",
+ redirect: 'follow',
signal: AbortSignal.timeout(monitor.timeoutMs),
- headers: { "User-Agent": "Upwatch/1.0 (+uptime monitor)" },
+ headers: { 'User-Agent': 'Upwatch/1.0 (+uptime monitor)' },
});
await response.body?.cancel();
const ok = response.status === monitor.expectedStatus;
@@ -24,18 +24,14 @@ export async function runCheck(monitor: Monitor): Promise {
ok,
statusCode: response.status,
latencyMs: Date.now() - startedAt,
- error: ok
- ? null
- : `Expected HTTP ${monitor.expectedStatus}, received ${response.status}`,
+ error: ok ? null : `Expected HTTP ${monitor.expectedStatus}, received ${response.status}`,
};
} catch (error) {
return {
ok: false,
statusCode: null,
latencyMs: Date.now() - startedAt,
- error: error instanceof Error
- ? error.message.slice(0, 200)
- : "Request failed",
+ error: error instanceof Error ? error.message.slice(0, 200) : 'Request failed',
};
}
}
diff --git a/src/worker/checks/run-due-checks.ts b/src/worker/checks/run-due-checks.ts
index 48ad358..1e24f9f 100644
--- a/src/worker/checks/run-due-checks.ts
+++ b/src/worker/checks/run-due-checks.ts
@@ -1,9 +1,9 @@
-import { and, eq, sql } from "drizzle-orm";
-import { getDb } from "../db/client";
-import { monitors } from "../db/schema";
-import { sendIncidentAlert } from "../notifications/webhook";
-import { buildResultStatements } from "./persist-result";
-import { runCheck } from "./run-check";
+import { and, eq, sql } from 'drizzle-orm';
+import { getDb } from '../db/client';
+import { monitors } from '../db/schema';
+import { sendIncidentAlert } from '../notifications/webhook';
+import { buildResultStatements } from './persist-result';
+import { runCheck } from './run-check';
const MAX_MONITORS_PER_RUN = 40;
const CONCURRENCY = 10;
@@ -14,10 +14,7 @@ export type DueCheckSummary = {
down: number;
};
-export async function runDueChecks(
- env: Env,
- ctx?: Pick,
-): Promise {
+export async function runDueChecks(env: Env, ctx?: Pick): Promise {
const db = getDb(env);
const now = Date.now();
const due = await db
@@ -60,16 +57,20 @@ export async function runDueChecks(
}));
const statements = persisted.flatMap((item) => item.statements);
- await db.batch(statements as [typeof statements[number], ...typeof statements]);
+ await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
- const notifications = persisted.flatMap((item) => item.transition === null ? [] : [
- sendIncidentAlert(env, {
- monitor: item.monitor,
- kind: item.transition,
- result: item.result,
- at: item.checkedAt,
- }),
- ]);
+ const notifications = persisted.flatMap((item) =>
+ item.transition === null
+ ? []
+ : [
+ sendIncidentAlert(env, {
+ monitor: item.monitor,
+ kind: item.transition,
+ result: item.result,
+ at: item.checkedAt,
+ }),
+ ],
+ );
if (notifications.length > 0) {
const notificationWork = Promise.all(notifications).then(() => undefined);
if (ctx) ctx.waitUntil(notificationWork);
diff --git a/src/worker/db/client.ts b/src/worker/db/client.ts
index 74d9dec..84167cd 100644
--- a/src/worker/db/client.ts
+++ b/src/worker/db/client.ts
@@ -1,5 +1,5 @@
-import { drizzle } from "drizzle-orm/d1";
-import * as schema from "./schema";
+import { drizzle } from 'drizzle-orm/d1';
+import * as schema from './schema';
export const getDb = (env: Env) => drizzle(env.DB, { schema });
diff --git a/src/worker/db/schema.ts b/src/worker/db/schema.ts
index 539b4c3..7954e45 100644
--- a/src/worker/db/schema.ts
+++ b/src/worker/db/schema.ts
@@ -1,125 +1,111 @@
-import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
+import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
-export const adminCredentials = sqliteTable("admin_credentials", {
- id: integer("id").primaryKey(),
- passwordHash: text("password_hash").notNull(),
- createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
- updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
+export const adminCredentials = sqliteTable('admin_credentials', {
+ id: integer('id').primaryKey(),
+ passwordHash: text('password_hash').notNull(),
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
-export const notificationSettings = sqliteTable("notification_settings", {
- id: integer("id").primaryKey(),
- webhookUrl: text("webhook_url"),
- webhookEnabled: integer("webhook_enabled", { mode: "boolean" }).notNull().default(false),
- createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
- updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
+export const notificationSettings = sqliteTable('notification_settings', {
+ id: integer('id').primaryKey(),
+ webhookUrl: text('webhook_url'),
+ webhookEnabled: integer('webhook_enabled', { mode: 'boolean' }).notNull().default(false),
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const sessions = sqliteTable(
- "sessions",
+ 'sessions',
{
- id: text("id").primaryKey(),
- expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
- createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
- userAgent: text("user_agent"),
+ id: text('id').primaryKey(),
+ expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
+ userAgent: text('user_agent'),
},
- (table) => [index("sessions_expires_at_idx").on(table.expiresAt)],
+ (table) => [index('sessions_expires_at_idx').on(table.expiresAt)],
);
export const loginAttempts = sqliteTable(
- "login_attempts",
+ 'login_attempts',
{
- id: integer("id").primaryKey({ autoIncrement: true }),
- ipAddress: text("ip_address").notNull(),
- attemptedAt: integer("attempted_at", { mode: "timestamp_ms" }).notNull(),
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ ipAddress: text('ip_address').notNull(),
+ attemptedAt: integer('attempted_at', { mode: 'timestamp_ms' }).notNull(),
},
- (table) => [index("login_attempts_ip_attempted_at_idx").on(table.ipAddress, table.attemptedAt)],
+ (table) => [index('login_attempts_ip_attempted_at_idx').on(table.ipAddress, table.attemptedAt)],
);
export const monitors = sqliteTable(
- "monitors",
+ 'monitors',
{
- id: integer("id").primaryKey({ autoIncrement: true }),
- name: text("name").notNull(),
- url: text("url").notNull(),
- method: text("method").notNull().default("GET"),
- expectedStatus: integer("expected_status").notNull().default(200),
- intervalSeconds: integer("interval_seconds").notNull().default(300),
- timeoutMs: integer("timeout_ms").notNull().default(10_000),
- enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
- alertsEnabled: integer("alerts_enabled", { mode: "boolean" }).notNull().default(true),
- lastOk: integer("last_ok", { mode: "boolean" }),
- lastStatusCode: integer("last_status_code"),
- lastLatencyMs: integer("last_latency_ms"),
- lastError: text("last_error"),
- lastCheckedAt: integer("last_checked_at", { mode: "timestamp_ms" }),
- createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
- updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ name: text('name').notNull(),
+ url: text('url').notNull(),
+ method: text('method').notNull().default('GET'),
+ expectedStatus: integer('expected_status').notNull().default(200),
+ intervalSeconds: integer('interval_seconds').notNull().default(300),
+ timeoutMs: integer('timeout_ms').notNull().default(10_000),
+ enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
+ alertsEnabled: integer('alerts_enabled', { mode: 'boolean' }).notNull().default(true),
+ lastOk: integer('last_ok', { mode: 'boolean' }),
+ lastStatusCode: integer('last_status_code'),
+ lastLatencyMs: integer('last_latency_ms'),
+ lastError: text('last_error'),
+ lastCheckedAt: integer('last_checked_at', { mode: 'timestamp_ms' }),
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
},
- (table) => [
- index("monitors_enabled_last_checked_at_idx").on(
- table.enabled,
- table.lastCheckedAt,
- ),
- ],
+ (table) => [index('monitors_enabled_last_checked_at_idx').on(table.enabled, table.lastCheckedAt)],
);
export const checks = sqliteTable(
- "checks",
+ 'checks',
{
- id: integer("id").primaryKey({ autoIncrement: true }),
- monitorId: integer("monitor_id")
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ monitorId: integer('monitor_id')
.notNull()
- .references(() => monitors.id, { onDelete: "cascade" }),
- ok: integer("ok", { mode: "boolean" }).notNull(),
- statusCode: integer("status_code"),
- latencyMs: integer("latency_ms"),
- error: text("error"),
- checkedAt: integer("checked_at", { mode: "timestamp_ms" }).notNull(),
+ .references(() => monitors.id, { onDelete: 'cascade' }),
+ ok: integer('ok', { mode: 'boolean' }).notNull(),
+ statusCode: integer('status_code'),
+ latencyMs: integer('latency_ms'),
+ error: text('error'),
+ checkedAt: integer('checked_at', { mode: 'timestamp_ms' }).notNull(),
},
- (table) => [
- index("checks_monitor_id_checked_at_idx").on(
- table.monitorId,
- table.checkedAt,
- ),
- ],
+ (table) => [index('checks_monitor_id_checked_at_idx').on(table.monitorId, table.checkedAt)],
);
export const incidents = sqliteTable(
- "incidents",
+ 'incidents',
{
- id: integer("id").primaryKey({ autoIncrement: true }),
- monitorId: integer("monitor_id")
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ monitorId: integer('monitor_id')
.notNull()
- .references(() => monitors.id, { onDelete: "cascade" }),
- startedAt: integer("started_at", { mode: "timestamp_ms" }).notNull(),
- resolvedAt: integer("resolved_at", { mode: "timestamp_ms" }),
- startStatusCode: integer("start_status_code"),
- startError: text("start_error"),
- durationMs: integer("duration_ms"),
- createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
- updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
+ .references(() => monitors.id, { onDelete: 'cascade' }),
+ startedAt: integer('started_at', { mode: 'timestamp_ms' }).notNull(),
+ resolvedAt: integer('resolved_at', { mode: 'timestamp_ms' }),
+ startStatusCode: integer('start_status_code'),
+ startError: text('start_error'),
+ durationMs: integer('duration_ms'),
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
+ updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
},
- (table) => [
- index("incidents_monitor_id_started_at_idx").on(table.monitorId, table.startedAt),
- ],
+ (table) => [index('incidents_monitor_id_started_at_idx').on(table.monitorId, table.startedAt)],
);
export const monitorDailyStats = sqliteTable(
- "monitor_daily_stats",
+ 'monitor_daily_stats',
{
- id: integer("id").primaryKey({ autoIncrement: true }),
- monitorId: integer("monitor_id")
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ monitorId: integer('monitor_id')
.notNull()
- .references(() => monitors.id, { onDelete: "cascade" }),
- day: integer("day", { mode: "timestamp_ms" }).notNull(),
- totalChecks: integer("total_checks").notNull(),
- upChecks: integer("up_checks").notNull(),
- avgLatencyMs: integer("avg_latency_ms"),
- minLatencyMs: integer("min_latency_ms"),
- maxLatencyMs: integer("max_latency_ms"),
+ .references(() => monitors.id, { onDelete: 'cascade' }),
+ day: integer('day', { mode: 'timestamp_ms' }).notNull(),
+ totalChecks: integer('total_checks').notNull(),
+ upChecks: integer('up_checks').notNull(),
+ avgLatencyMs: integer('avg_latency_ms'),
+ minLatencyMs: integer('min_latency_ms'),
+ maxLatencyMs: integer('max_latency_ms'),
},
- (table) => [
- uniqueIndex("monitor_daily_stats_monitor_id_day_uidx").on(table.monitorId, table.day),
- ],
+ (table) => [uniqueIndex('monitor_daily_stats_monitor_id_day_uidx').on(table.monitorId, table.day)],
);
diff --git a/src/worker/index.ts b/src/worker/index.ts
index 15307d1..23a110e 100644
--- a/src/worker/index.ts
+++ b/src/worker/index.ts
@@ -1,19 +1,19 @@
-import { Hono } from "hono";
-import { csrf } from "hono/csrf";
-import { runDueChecks } from "./checks/run-due-checks";
-import authRoutes from "./routes/auth";
-import monitorRoutes from "./routes/monitors";
-import settingsRoutes from "./routes/settings";
-import statusRoutes from "./routes/status";
-import { cleanupExpiredAuthRecords } from "./scheduled/cleanup";
-import { runDailyRollup } from "./scheduled/rollup";
+import { Hono } from 'hono';
+import { csrf } from 'hono/csrf';
+import { runDueChecks } from './checks/run-due-checks';
+import authRoutes from './routes/auth';
+import monitorRoutes from './routes/monitors';
+import settingsRoutes from './routes/settings';
+import statusRoutes from './routes/status';
+import { cleanupExpiredAuthRecords } from './scheduled/cleanup';
+import { runDailyRollup } from './scheduled/rollup';
const app = new Hono<{ Bindings: Env }>();
-app.use("/api/*", csrf());
+app.use('/api/*', csrf());
-app.get("/api/health", async (context) => {
- const db = await context.env.DB.prepare("SELECT 1 AS ok").first<{
+app.get('/api/health', async (context) => {
+ const db = await context.env.DB.prepare('SELECT 1 AS ok').first<{
ok: number;
}>();
@@ -24,22 +24,24 @@ app.get("/api/health", async (context) => {
});
});
-app.route("/", authRoutes);
-app.route("/api/monitors", monitorRoutes);
-app.route("/api/settings", settingsRoutes);
-app.route("/api/status", statusRoutes);
+app.route('/', authRoutes);
+app.route('/api/monitors', monitorRoutes);
+app.route('/api/settings', settingsRoutes);
+app.route('/api/status', statusRoutes);
export default {
fetch: app.fetch,
async scheduled(controller, env, ctx) {
- if (controller.cron === "5 0 * * *") {
+ if (controller.cron === '5 0 * * *') {
const result = await runDailyRollup(env, new Date(controller.scheduledTime));
- console.log(JSON.stringify({
- message: "daily rollup completed",
- cron: controller.cron,
- scheduledTime: controller.scheduledTime,
- ...result,
- }));
+ console.log(
+ JSON.stringify({
+ message: 'daily rollup completed',
+ cron: controller.cron,
+ scheduledTime: controller.scheduledTime,
+ ...result,
+ }),
+ );
return;
}
@@ -47,7 +49,7 @@ export default {
const result = await runDueChecks(env, ctx);
console.log(
JSON.stringify({
- message: "scheduled run completed",
+ message: 'scheduled run completed',
cron: controller.cron,
scheduledTime: controller.scheduledTime,
...result,
diff --git a/src/worker/lib/password.ts b/src/worker/lib/password.ts
index 526fc04..bd6834a 100644
--- a/src/worker/lib/password.ts
+++ b/src/worker/lib/password.ts
@@ -1,4 +1,4 @@
-const HASH_ALGORITHM = "SHA-256";
+const HASH_ALGORITHM = 'SHA-256';
const HASH_BYTES = 32;
const SALT_BYTES = 16;
@@ -7,7 +7,7 @@ const SALT_BYTES = 16;
export const PBKDF2_ITERATIONS = 25_000;
function bytesToBase64(bytes: Uint8Array) {
- let binary = "";
+ let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
@@ -18,18 +18,12 @@ function base64ToBytes(value: string) {
}
async function derivePassword(plain: string, salt: ArrayBuffer, iterations: number) {
- const key = await crypto.subtle.importKey(
- "raw",
- new TextEncoder().encode(plain),
- "PBKDF2",
- false,
- ["deriveBits"],
- );
+ const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(plain), 'PBKDF2', false, ['deriveBits']);
return new Uint8Array(
await crypto.subtle.deriveBits(
{
- name: "PBKDF2",
+ name: 'PBKDF2',
hash: HASH_ALGORITHM,
salt,
iterations,
@@ -48,12 +42,12 @@ export async function hashPassword(plain: string) {
}
export async function verifyPassword(plain: string, stored: string) {
- const [scheme, digest, iterationValue, saltValue, hashValue, ...extra] = stored.split("$");
+ const [scheme, digest, iterationValue, saltValue, hashValue, ...extra] = stored.split('$');
const iterations = Number(iterationValue);
if (
- scheme !== "pbkdf2" ||
- digest !== "sha256" ||
+ scheme !== 'pbkdf2' ||
+ digest !== 'sha256' ||
extra.length > 0 ||
!Number.isSafeInteger(iterations) ||
iterations < 1 ||
@@ -69,11 +63,7 @@ export async function verifyPassword(plain: string, stored: string) {
const expected = base64ToBytes(hashValue);
if (salt.length !== SALT_BYTES || expected.length !== HASH_BYTES) return false;
- const actual = await derivePassword(
- plain,
- Uint8Array.from(salt).buffer,
- iterations,
- );
+ const actual = await derivePassword(plain, Uint8Array.from(salt).buffer, iterations);
const subtle = crypto.subtle as SubtleCrypto & {
timingSafeEqual(a: ArrayBufferView, b: ArrayBufferView): boolean;
};
diff --git a/src/worker/lib/require-auth.ts b/src/worker/lib/require-auth.ts
index a8ca37f..019309d 100644
--- a/src/worker/lib/require-auth.ts
+++ b/src/worker/lib/require-auth.ts
@@ -1,7 +1,7 @@
-import { getCookie } from "hono/cookie";
-import { createMiddleware } from "hono/factory";
-import { getDb } from "../db/client";
-import { hasValidSession, SESSION_COOKIE } from "./session";
+import { getCookie } from 'hono/cookie';
+import { createMiddleware } from 'hono/factory';
+import { getDb } from '../db/client';
+import { hasValidSession, SESSION_COOKIE } from './session';
export type AuthVariables = {
authenticated: true;
@@ -12,11 +12,11 @@ export const requireAuth = createMiddleware<{
Variables: AuthVariables;
}>(async (context, next) => {
const token = getCookie(context, SESSION_COOKIE);
- if (!token) return context.json({ message: "Authentication required" }, 401);
+ if (!token) return context.json({ message: 'Authentication required' }, 401);
const authenticated = await hasValidSession(getDb(context.env), token);
- if (!authenticated) return context.json({ message: "Authentication required" }, 401);
+ if (!authenticated) return context.json({ message: 'Authentication required' }, 401);
- context.set("authenticated", true);
+ context.set('authenticated', true);
await next();
});
diff --git a/src/worker/lib/session.ts b/src/worker/lib/session.ts
index 491c668..b687590 100644
--- a/src/worker/lib/session.ts
+++ b/src/worker/lib/session.ts
@@ -1,38 +1,33 @@
-import { and, eq, gt } from "drizzle-orm";
-import type { CookieOptions } from "hono/utils/cookie";
-import type { Database } from "../db/client";
-import { sessions } from "../db/schema";
+import { and, eq, gt } from 'drizzle-orm';
+import type { CookieOptions } from 'hono/utils/cookie';
+import type { Database } from '../db/client';
+import { sessions } from '../db/schema';
-export const SESSION_COOKIE = "upwatch_session";
+export const SESSION_COOKIE = 'upwatch_session';
export const SESSION_DURATION_SECONDS = 7 * 24 * 60 * 60;
function bytesToBase64Url(bytes: Uint8Array) {
- let binary = "";
+ let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
- return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
}
async function sha256Hex(value: string) {
- const digest = new Uint8Array(
- await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)),
- );
- return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)));
+ return Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('');
}
export function sessionCookieOptions(requestUrl: string): CookieOptions {
return {
httpOnly: true,
- sameSite: "Lax",
- path: "/",
+ sameSite: 'Lax',
+ path: '/',
maxAge: SESSION_DURATION_SECONDS,
- secure: new URL(requestUrl).protocol === "https:",
+ secure: new URL(requestUrl).protocol === 'https:',
};
}
-export async function createSession(
- db: Database,
- userAgent: string | null,
-) {
+export async function createSession(db: Database, userAgent: string | null) {
const token = bytesToBase64Url(crypto.getRandomValues(new Uint8Array(32)));
const now = new Date();
@@ -46,19 +41,11 @@ export async function createSession(
return token;
}
-export async function hasValidSession(
- db: Database,
- token: string,
-): Promise {
+export async function hasValidSession(db: Database, token: string): Promise {
const [result] = await db
.select({ id: sessions.id })
.from(sessions)
- .where(
- and(
- eq(sessions.id, await sha256Hex(token)),
- gt(sessions.expiresAt, new Date()),
- ),
- )
+ .where(and(eq(sessions.id, await sha256Hex(token)), gt(sessions.expiresAt, new Date())))
.limit(1);
return result !== undefined;
diff --git a/src/worker/notifications/webhook.ts b/src/worker/notifications/webhook.ts
index 5662361..04bcea4 100644
--- a/src/worker/notifications/webhook.ts
+++ b/src/worker/notifications/webhook.ts
@@ -1,17 +1,17 @@
-import { eq } from "drizzle-orm";
-import type { CheckResult, Monitor } from "../checks/run-check";
-import { getDb } from "../db/client";
-import { notificationSettings } from "../db/schema";
+import { eq } from 'drizzle-orm';
+import type { CheckResult, Monitor } from '../checks/run-check';
+import { getDb } from '../db/client';
+import { notificationSettings } from '../db/schema';
export type IncidentAlert = {
monitor: Monitor;
- kind: "opened" | "resolved";
+ kind: 'opened' | 'resolved';
result: CheckResult;
at: Date;
};
type WebhookPayload = {
- event: "down" | "recovered" | "test";
+ event: 'down' | 'recovered' | 'test';
monitor: { id: number; name: string; url: string };
statusCode: number | null;
error: string | null;
@@ -20,10 +20,10 @@ type WebhookPayload = {
async function postWebhook(url: string, payload: WebhookPayload): Promise {
const response = await fetch(url, {
- method: "POST",
+ method: 'POST',
headers: {
- "Content-Type": "application/json",
- "User-Agent": "Upwatch/1.0 (+incident webhook)",
+ 'Content-Type': 'application/json',
+ 'User-Agent': 'Upwatch/1.0 (+incident webhook)',
},
body: JSON.stringify(payload),
});
@@ -34,59 +34,58 @@ async function postWebhook(url: string, payload: WebhookPayload): Promise {
try {
return await postWebhook(url, {
- event: "test",
- monitor: { id: 0, name: "Upwatch test", url: "https://example.com/health" },
+ event: 'test',
+ monitor: { id: 0, name: 'Upwatch test', url: 'https://example.com/health' },
statusCode: 200,
error: null,
at: new Date().toISOString(),
});
} catch (error) {
- console.error(JSON.stringify({
- message: "test webhook failed",
- error: error instanceof Error ? error.message : String(error),
- }));
+ console.error(
+ JSON.stringify({
+ message: 'test webhook failed',
+ error: error instanceof Error ? error.message : String(error),
+ }),
+ );
return false;
}
}
-export async function sendIncidentAlert(
- env: Env,
- alert: IncidentAlert,
-): Promise {
+export async function sendIncidentAlert(env: Env, alert: IncidentAlert): Promise {
if (!alert.monitor.alertsEnabled) return false;
try {
- const [settings] = await getDb(env)
- .select()
- .from(notificationSettings)
- .where(eq(notificationSettings.id, 1))
- .limit(1);
+ const [settings] = await getDb(env).select().from(notificationSettings).where(eq(notificationSettings.id, 1)).limit(1);
if (!settings?.webhookEnabled || !settings.webhookUrl) return false;
const ok = await postWebhook(settings.webhookUrl, {
- event: alert.kind === "opened" ? "down" : "recovered",
- monitor: {
- id: alert.monitor.id,
- name: alert.monitor.name,
- url: alert.monitor.url,
- },
- statusCode: alert.result.statusCode,
- error: alert.result.error,
- at: alert.at.toISOString(),
+ event: alert.kind === 'opened' ? 'down' : 'recovered',
+ monitor: {
+ id: alert.monitor.id,
+ name: alert.monitor.name,
+ url: alert.monitor.url,
+ },
+ statusCode: alert.result.statusCode,
+ error: alert.result.error,
+ at: alert.at.toISOString(),
});
if (!ok) {
- console.warn(JSON.stringify({
- message: "incident webhook returned an error",
- monitorId: alert.monitor.id,
- }));
+ console.warn(
+ JSON.stringify({
+ message: 'incident webhook returned an error',
+ monitorId: alert.monitor.id,
+ }),
+ );
}
return ok;
} catch (error) {
- console.error(JSON.stringify({
- message: "incident webhook failed",
- error: error instanceof Error ? error.message : String(error),
- monitorId: alert.monitor.id,
- }));
+ console.error(
+ JSON.stringify({
+ message: 'incident webhook failed',
+ error: error instanceof Error ? error.message : String(error),
+ monitorId: alert.monitor.id,
+ }),
+ );
return false;
}
}
diff --git a/src/worker/routes/auth.ts b/src/worker/routes/auth.ts
index 062cfdd..a3b8392 100644
--- a/src/worker/routes/auth.ts
+++ b/src/worker/routes/auth.ts
@@ -1,17 +1,11 @@
-import { and, count, eq, gte } from "drizzle-orm";
-import { Hono } from "hono";
-import { deleteCookie, getCookie, setCookie } from "hono/cookie";
-import { getDb } from "../db/client";
-import { adminCredentials, loginAttempts } from "../db/schema";
-import { verifyPassword } from "../lib/password";
-import { requireAuth, type AuthVariables } from "../lib/require-auth";
-import {
- createSession,
- hasValidSession,
- revokeSession,
- SESSION_COOKIE,
- sessionCookieOptions,
-} from "../lib/session";
+import { and, count, eq, gte } from 'drizzle-orm';
+import { Hono } from 'hono';
+import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
+import { getDb } from '../db/client';
+import { adminCredentials, loginAttempts } from '../db/schema';
+import { verifyPassword } from '../lib/password';
+import { requireAuth, type AuthVariables } from '../lib/require-auth';
+import { createSession, hasValidSession, revokeSession, SESSION_COOKIE, sessionCookieOptions } from '../lib/session';
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
const MAX_FAILED_ATTEMPTS = 10;
@@ -23,42 +17,28 @@ type LoginBody = {
const authRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
-authRoutes.post("/api/auth/login", async (context) => {
+authRoutes.post('/api/auth/login', async (context) => {
let body: LoginBody;
try {
body = await context.req.json();
} catch {
- return context.json({ message: "Invalid request body" }, 400);
+ return context.json({ message: 'Invalid request body' }, 400);
}
- if (
- typeof body.password !== "string" ||
- body.password.length < 8
- ) {
- return context.json(
- { message: "Enter a password of at least 8 characters" },
- 400,
- );
+ if (typeof body.password !== 'string' || body.password.length < 8) {
+ return context.json({ message: 'Enter a password of at least 8 characters' }, 400);
}
const db = getDb(context.env);
- const ipAddress = context.req.header("CF-Connecting-IP") ?? "unknown";
+ const ipAddress = context.req.header('CF-Connecting-IP') ?? 'unknown';
const cutoff = new Date(Date.now() - LOGIN_WINDOW_MS);
const [attemptResult] = await db
.select({ value: count() })
.from(loginAttempts)
- .where(
- and(
- eq(loginAttempts.ipAddress, ipAddress),
- gte(loginAttempts.attemptedAt, cutoff),
- ),
- );
+ .where(and(eq(loginAttempts.ipAddress, ipAddress), gte(loginAttempts.attemptedAt, cutoff)));
if ((attemptResult?.value ?? 0) >= MAX_FAILED_ATTEMPTS) {
- return context.json(
- { message: "Too many login attempts. Try again later" },
- 429,
- );
+ return context.json({ message: 'Too many login attempts. Try again later' }, 429);
}
const [credential] = await db
@@ -68,41 +48,34 @@ authRoutes.post("/api/auth/login", async (context) => {
.from(adminCredentials)
.where(eq(adminCredentials.id, ADMIN_CREDENTIAL_ID))
.limit(1);
- const passwordMatches = credential
- ? await verifyPassword(body.password, credential.passwordHash)
- : false;
+ const passwordMatches = credential ? await verifyPassword(body.password, credential.passwordHash) : false;
if (!credential || !passwordMatches) {
await db.insert(loginAttempts).values({ ipAddress, attemptedAt: new Date() });
- return context.json({ message: "Password is incorrect" }, 401);
+ return context.json({ message: 'Password is incorrect' }, 401);
}
await db.delete(loginAttempts).where(eq(loginAttempts.ipAddress, ipAddress));
- const token = await createSession(
- db,
- context.req.header("User-Agent") ?? null,
- );
+ const token = await createSession(db, context.req.header('User-Agent') ?? null);
setCookie(context, SESSION_COOKIE, token, sessionCookieOptions(context.req.url));
return context.json({ authenticated: true });
});
-authRoutes.post("/api/auth/logout", requireAuth, async (context) => {
+authRoutes.post('/api/auth/logout', requireAuth, async (context) => {
const token = getCookie(context, SESSION_COOKIE);
if (token) await revokeSession(getDb(context.env), token);
deleteCookie(context, SESSION_COOKIE, {
- path: "/",
- secure: new URL(context.req.url).protocol === "https:",
+ path: '/',
+ secure: new URL(context.req.url).protocol === 'https:',
});
return context.json({ ok: true });
});
-authRoutes.get("/api/auth/me", async (context) => {
+authRoutes.get('/api/auth/me', async (context) => {
const token = getCookie(context, SESSION_COOKIE);
- const authenticated = token
- ? await hasValidSession(getDb(context.env), token)
- : false;
+ const authenticated = token ? await hasValidSession(getDb(context.env), token) : false;
return context.json({ authenticated });
});
diff --git a/src/worker/routes/monitors.ts b/src/worker/routes/monitors.ts
index f7cd90a..2e797b8 100644
--- a/src/worker/routes/monitors.ts
+++ b/src/worker/routes/monitors.ts
@@ -1,13 +1,13 @@
-import { and, desc, eq, gte, isNull, lt, or, sql } from "drizzle-orm";
-import { Hono } from "hono";
-import { buildResultStatements } from "../checks/persist-result";
-import { runCheck } from "../checks/run-check";
-import { getDb } from "../db/client";
-import { checks, incidents, monitorDailyStats, monitors } from "../db/schema";
-import { requireAuth, type AuthVariables } from "../lib/require-auth";
-import { sendIncidentAlert } from "../notifications/webhook";
+import { and, desc, eq, gte, isNull, or, sql } from 'drizzle-orm';
+import { Hono } from 'hono';
+import { buildResultStatements } from '../checks/persist-result';
+import { runCheck } from '../checks/run-check';
+import { getDb } from '../db/client';
+import { checks, incidents, monitors } from '../db/schema';
+import { requireAuth, type AuthVariables } from '../lib/require-auth';
+import { sendIncidentAlert } from '../notifications/webhook';
-type MonitorMethod = "GET" | "HEAD" | "POST";
+type MonitorMethod = 'GET' | 'HEAD' | 'POST';
type ParsedMonitorInput = {
name?: string;
@@ -20,11 +20,9 @@ type ParsedMonitorInput = {
alertsEnabled?: boolean;
};
-type ParseResult =
- | { ok: true; value: ParsedMonitorInput }
- | { ok: false; message: string };
+type ParseResult = { ok: true; value: ParsedMonitorInput } | { ok: false; message: string };
-const METHODS = new Set(["GET", "HEAD", "POST"]);
+const METHODS = new Set(['GET', 'HEAD', 'POST']);
const FAVICON_CACHE_SECONDS = 86_400;
const FAVICON_FETCH_TIMEOUT_MS = 5_000;
const MAX_FAVICON_BYTES = 1024 * 1024;
@@ -42,31 +40,33 @@ type EdgeCache = {
};
function isPrivateHostname(rawHostname: string): boolean {
- const hostname = rawHostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
- if (hostname === "localhost" || hostname.endsWith(".localhost")) return true;
+ const hostname = rawHostname
+ .toLowerCase()
+ .replace(/^\[|\]$/g, '')
+ .replace(/\.$/, '');
+ if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
- const ipv4 = hostname.split(".").map(Number);
+ const ipv4 = hostname.split('.').map(Number);
if (ipv4.length === 4 && ipv4.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
const [first, second] = ipv4;
- return first === 0
- || first === 10
- || first === 127
- || (first === 169 && second === 254)
- || (first === 172 && second >= 16 && second <= 31)
- || (first === 192 && second === 168);
+ return (
+ first === 0 ||
+ first === 10 ||
+ first === 127 ||
+ (first === 169 && second === 254) ||
+ (first === 172 && second >= 16 && second <= 31) ||
+ (first === 192 && second === 168)
+ );
}
- if (hostname === "::" || hostname === "::1") return true;
+ if (hostname === '::' || hostname === '::1') return true;
if (/^f[cd][0-9a-f]{2}(?::|$)/i.test(hostname) || /^fe[89ab][0-9a-f](?::|$)/i.test(hostname)) return true;
const mappedIpv4 = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
return mappedIpv4 ? isPrivateHostname(mappedIpv4[1]) : false;
}
function isSafeRemoteUrl(url: URL) {
- return (url.protocol === "http:" || url.protocol === "https:")
- && !url.username
- && !url.password
- && !isPrivateHostname(url.hostname);
+ return (url.protocol === 'http:' || url.protocol === 'https:') && !url.username && !url.password && !isPrivateHostname(url.hostname);
}
async function readBodyLimited(response: Response, maximum: number, truncate: boolean) {
@@ -117,16 +117,16 @@ async function fetchRemote(url: URL, maximumBytes: number, truncate = false) {
if (!isSafeRemoteUrl(currentUrl)) return null;
const response = await fetch(currentUrl, {
headers: {
- Accept: "image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,text/html;q=0.5,*/*;q=0.1",
- "User-Agent": "Upwatch Favicon Proxy/1.0",
+ Accept: 'image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,text/html;q=0.5,*/*;q=0.1',
+ 'User-Agent': 'Upwatch Favicon Proxy/1.0',
},
- redirect: "manual",
+ redirect: 'manual',
signal: controller.signal,
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
if (response.body) await response.body.cancel().catch(() => undefined);
- const location = response.headers.get("Location");
+ const location = response.headers.get('Location');
if (!location || redirects === MAX_REDIRECTS) return null;
try {
currentUrl = new URL(location, currentUrl);
@@ -141,7 +141,7 @@ async function fetchRemote(url: URL, maximumBytes: number, truncate = false) {
return null;
}
- const declaredLength = Number(response.headers.get("Content-Length"));
+ const declaredLength = Number(response.headers.get('Content-Length'));
if (!truncate && Number.isFinite(declaredLength) && declaredLength > maximumBytes) {
if (response.body) await response.body.cancel().catch(() => undefined);
return null;
@@ -161,9 +161,9 @@ async function fetchRemote(url: URL, maximumBytes: number, truncate = false) {
}
function imageContentType(headers: Headers) {
- const contentType = headers.get("Content-Type")?.split(";", 1)[0].trim().toLowerCase();
- if (!contentType || contentType === "application/octet-stream") return "image/x-icon";
- if (!contentType.startsWith("image/")) {
+ const contentType = headers.get('Content-Type')?.split(';', 1)[0].trim().toLowerCase();
+ if (!contentType || contentType === 'application/octet-stream') return 'image/x-icon';
+ if (!contentType.startsWith('image/')) {
return null;
}
return contentType;
@@ -172,7 +172,7 @@ function imageContentType(headers: Headers) {
function readTagAttribute(tag: string, name: string) {
const attributes = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
for (const match of tag.matchAll(attributes)) {
- if (match[1].toLowerCase() === name) return match[2] ?? match[3] ?? match[4] ?? "";
+ if (match[1].toLowerCase() === name) return match[2] ?? match[3] ?? match[4] ?? '';
}
return null;
}
@@ -182,7 +182,7 @@ function findFaviconUrl(html: string, pageUrl: URL) {
const head = closingHead >= 0 ? html.slice(0, closingHead) : html;
let baseUrl = pageUrl;
const baseTag = head.match(/ ]*>/i)?.[0];
- const baseHref = baseTag ? readTagAttribute(baseTag, "href") : null;
+ const baseHref = baseTag ? readTagAttribute(baseTag, 'href') : null;
if (baseHref) {
try {
const candidate = new URL(baseHref, pageUrl);
@@ -193,9 +193,9 @@ function findFaviconUrl(html: string, pageUrl: URL) {
}
for (const match of head.matchAll(/ ]*>/gi)) {
- const rel = readTagAttribute(match[0], "rel")?.toLowerCase().split(/\s+/) ?? [];
- if (!rel.includes("icon") && !rel.includes("apple-touch-icon")) continue;
- const href = readTagAttribute(match[0], "href");
+ const rel = readTagAttribute(match[0], 'rel')?.toLowerCase().split(/\s+/) ?? [];
+ if (!rel.includes('icon') && !rel.includes('apple-touch-icon')) continue;
+ const href = readTagAttribute(match[0], 'href');
if (!href) continue;
try {
const faviconUrl = new URL(href, baseUrl);
@@ -217,7 +217,7 @@ export async function resolveFavicon(siteUrl: string): Promise 0) {
const contentType = imageContentType(defaultIcon.headers);
if (contentType) return { body: defaultIcon.body, contentType };
@@ -225,8 +225,8 @@ export async function resolveFavicon(siteUrl: string): Promise {
- return typeof value === "object" && value !== null && !Array.isArray(value);
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parseInteger(
@@ -258,40 +258,40 @@ function parseInteger(
}
export function parseMonitorInput(body: unknown, partial = false): ParseResult {
- if (!isRecord(body)) return { ok: false, message: "Invalid request body" };
+ if (!isRecord(body)) return { ok: false, message: 'Invalid request body' };
const value: ParsedMonitorInput = {};
- if (!partial || "name" in body) {
- if (typeof body.name !== "string" || body.name.trim().length < 1 || body.name.trim().length > 100) {
- return { ok: false, message: "Name must be between 1 and 100 characters" };
+ if (!partial || 'name' in body) {
+ if (typeof body.name !== 'string' || body.name.trim().length < 1 || body.name.trim().length > 100) {
+ return { ok: false, message: 'Name must be between 1 and 100 characters' };
}
value.name = body.name.trim();
}
- if (!partial || "url" in body) {
- if (typeof body.url !== "string") {
- return { ok: false, message: "Enter a valid http or https URL" };
+ if (!partial || 'url' in body) {
+ if (typeof body.url !== 'string') {
+ return { ok: false, message: 'Enter a valid http or https URL' };
}
try {
const url = new URL(body.url);
- if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("Invalid protocol");
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('Invalid protocol');
value.url = url.toString();
} catch {
- return { ok: false, message: "Enter a valid http or https URL" };
+ return { ok: false, message: 'Enter a valid http or https URL' };
}
}
- if (!partial || "method" in body) {
- if (typeof body.method !== "string" || !METHODS.has(body.method as MonitorMethod)) {
- return { ok: false, message: "Method must be GET, HEAD, or POST" };
+ if (!partial || 'method' in body) {
+ if (typeof body.method !== 'string' || !METHODS.has(body.method as MonitorMethod)) {
+ return { ok: false, message: 'Method must be GET, HEAD, or POST' };
}
value.method = body.method as MonitorMethod;
}
for (const [key, label, minimum, maximum] of [
- ["expectedStatus", "expectedStatus", 100, 599],
- ["intervalSeconds", "intervalSeconds", 300, 86_400],
- ["timeoutMs", "timeoutMs", 1_000, 30_000],
+ ['expectedStatus', 'expectedStatus', 100, 599],
+ ['intervalSeconds', 'intervalSeconds', 300, 86_400],
+ ['timeoutMs', 'timeoutMs', 1_000, 30_000],
] as const) {
if (!partial || key in body) {
const parsed = parseInteger(body[key], label, minimum, maximum);
@@ -300,15 +300,15 @@ export function parseMonitorInput(body: unknown, partial = false): ParseResult {
}
}
- if ("enabled" in body) {
- if (typeof body.enabled !== "boolean") {
- return { ok: false, message: "enabled must be a boolean" };
+ if ('enabled' in body) {
+ if (typeof body.enabled !== 'boolean') {
+ return { ok: false, message: 'enabled must be a boolean' };
}
value.enabled = body.enabled;
}
- if ("alertsEnabled" in body) {
- if (typeof body.alertsEnabled !== "boolean") {
- return { ok: false, message: "alertsEnabled must be a boolean" };
+ if ('alertsEnabled' in body) {
+ if (typeof body.alertsEnabled !== 'boolean') {
+ return { ok: false, message: 'alertsEnabled must be a boolean' };
}
value.alertsEnabled = body.alertsEnabled;
}
@@ -335,10 +335,7 @@ type StatsWindow = {
incidentCount: number;
};
-function asStatsWindow(
- row: { totalChecks: number; upChecks: number; avgLatencyMs: number | null },
- incidentCount: number,
-): StatsWindow {
+function asStatsWindow(row: { totalChecks: number; upChecks: number; avgLatencyMs: number | null }, incidentCount: number): StatsWindow {
return {
uptimePct: row.totalChecks > 0 ? Math.round((row.upChecks / row.totalChecks) * 100_000) / 1_000 : null,
totalChecks: row.totalChecks,
@@ -350,38 +347,33 @@ function asStatsWindow(
const monitorRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
-monitorRoutes.use("*", requireAuth);
+monitorRoutes.use('*', requireAuth);
-monitorRoutes.get("/", async (context) => {
+monitorRoutes.get('/', async (context) => {
const rows = await getDb(context.env).select().from(monitors).orderBy(monitors.createdAt);
return context.json({ monitors: rows });
});
-monitorRoutes.get("/:id", async (context) => {
- const id = parseId(context.req.param("id"));
- if (id === null) return context.json({ message: "Monitor not found" }, 404);
+monitorRoutes.get('/:id', async (context) => {
+ const id = parseId(context.req.param('id'));
+ if (id === null) return context.json({ message: 'Monitor not found' }, 404);
const [monitor] = await getDb(context.env).select().from(monitors).where(eq(monitors.id, id)).limit(1);
- if (!monitor) return context.json({ message: "Monitor not found" }, 404);
+ if (!monitor) return context.json({ message: 'Monitor not found' }, 404);
return context.json({ monitor });
});
-monitorRoutes.get("/:id/checks", async (context) => {
- const id = parseId(context.req.param("id"));
- if (id === null) return context.json({ message: "Monitor not found" }, 404);
- const limit = parseLimit(context.req.query("limit"), 100, 500);
- const rows = await getDb(context.env)
- .select()
- .from(checks)
- .where(eq(checks.monitorId, id))
- .orderBy(desc(checks.checkedAt))
- .limit(limit);
+monitorRoutes.get('/:id/checks', async (context) => {
+ const id = parseId(context.req.param('id'));
+ if (id === null) return context.json({ message: 'Monitor not found' }, 404);
+ const limit = parseLimit(context.req.query('limit'), 100, 500);
+ const rows = await getDb(context.env).select().from(checks).where(eq(checks.monitorId, id)).orderBy(desc(checks.checkedAt)).limit(limit);
return context.json({ checks: rows });
});
-monitorRoutes.get("/:id/incidents", async (context) => {
- const id = parseId(context.req.param("id"));
- if (id === null) return context.json({ message: "Monitor not found" }, 404);
- const limit = parseLimit(context.req.query("limit"), 50, 200);
+monitorRoutes.get('/:id/incidents', async (context) => {
+ const id = parseId(context.req.param('id'));
+ if (id === null) return context.json({ message: 'Monitor not found' }, 404);
+ const limit = parseLimit(context.req.query('limit'), 50, 200);
const rows = await getDb(context.env)
.select()
.from(incidents)
@@ -391,34 +383,38 @@ monitorRoutes.get("/:id/incidents", async (context) => {
return context.json({ incidents: rows });
});
-monitorRoutes.get("/:id/stats", async (context) => {
- const id = parseId(context.req.param("id"));
- if (id === null) return context.json({ message: "Monitor not found" }, 404);
+monitorRoutes.get('/:id/stats', async (context) => {
+ const id = parseId(context.req.param('id'));
+ if (id === null) return context.json({ message: 'Monitor not found' }, 404);
const db = getDb(context.env);
const [monitor] = await db.select({ id: monitors.id }).from(monitors).where(eq(monitors.id, id)).limit(1);
- if (!monitor) return context.json({ message: "Monitor not found" }, 404);
+ if (!monitor) return context.json({ message: 'Monitor not found' }, 404);
const now = Date.now();
const currentDayMs = Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate());
const windows = [
- { key: "24h", start: now - 24 * 60 * 60 * 1000, raw: true },
- { key: "7d", start: now - 7 * 24 * 60 * 60 * 1000, raw: true },
- { key: "30d", start: currentDayMs - 29 * 24 * 60 * 60 * 1000, raw: false },
- { key: "90d", start: currentDayMs - 89 * 24 * 60 * 60 * 1000, raw: false },
+ { key: '24h', start: now - 24 * 60 * 60 * 1000, raw: true },
+ { key: '7d', start: now - 7 * 24 * 60 * 60 * 1000, raw: true },
+ { key: '30d', start: currentDayMs - 29 * 24 * 60 * 60 * 1000, raw: false },
+ { key: '90d', start: currentDayMs - 89 * 24 * 60 * 60 * 1000, raw: false },
] as const;
- const results = await Promise.all(windows.map(async (window) => {
- const [aggregate] = window.raw
- ? await db.select({
- totalChecks: sql`count(*)`,
- upChecks: sql`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
- avgLatencyMs: sql`round(avg(${checks.latencyMs}))`,
- }).from(checks).where(and(eq(checks.monitorId, id), gte(checks.checkedAt, new Date(window.start))))
- : await db.select({
- totalChecks: sql`coalesce(sum(total_checks), 0)`,
- upChecks: sql`coalesce(sum(up_checks), 0)`,
- avgLatencyMs: sql`round(sum(avg_latency_ms * total_checks) / nullif(sum(total_checks), 0))`,
- }).from(sql`(
+ const results = await Promise.all(
+ windows.map(async (window) => {
+ const [aggregate] = window.raw
+ ? await db
+ .select({
+ totalChecks: sql`count(*)`,
+ upChecks: sql`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
+ avgLatencyMs: sql`round(avg(${checks.latencyMs}))`,
+ })
+ .from(checks)
+ .where(and(eq(checks.monitorId, id), gte(checks.checkedAt, new Date(window.start))))
+ : await db.select({
+ totalChecks: sql`coalesce(sum(total_checks), 0)`,
+ upChecks: sql`coalesce(sum(up_checks), 0)`,
+ avgLatencyMs: sql`round(sum(avg_latency_ms * total_checks) / nullif(sum(total_checks), 0))`,
+ }).from(sql`(
select total_checks, up_checks, avg_latency_ms
from monitor_daily_stats
where monitor_id = ${id} and day >= ${window.start} and day < ${currentDayMs}
@@ -427,28 +423,28 @@ monitorRoutes.get("/:id/stats", async (context) => {
from checks
where monitor_id = ${id} and checked_at >= ${currentDayMs}
)`);
- const [incidentAggregate] = await db.select({ count: sql`count(*)` })
- .from(incidents)
- .where(and(
- eq(incidents.monitorId, id),
- gte(incidents.startedAt, new Date(window.start)),
- or(isNull(incidents.resolvedAt), gte(incidents.resolvedAt, new Date(window.start))),
- ));
- return [window.key, asStatsWindow(aggregate, incidentAggregate.count)] as const;
- }));
+ const [incidentAggregate] = await db
+ .select({ count: sql`count(*)` })
+ .from(incidents)
+ .where(
+ and(
+ eq(incidents.monitorId, id),
+ gte(incidents.startedAt, new Date(window.start)),
+ or(isNull(incidents.resolvedAt), gte(incidents.resolvedAt, new Date(window.start))),
+ ),
+ );
+ return [window.key, asStatsWindow(aggregate, incidentAggregate.count)] as const;
+ }),
+ );
- return context.json({ windows: Object.fromEntries(results) as Record<(typeof windows)[number]["key"], StatsWindow> });
+ return context.json({ windows: Object.fromEntries(results) as Record<(typeof windows)[number]['key'], StatsWindow> });
});
-monitorRoutes.get("/:id/favicon", async (context) => {
- const id = parseId(context.req.param("id"));
- if (id === null) return context.json({ message: "Monitor not found" }, 404);
- const [monitor] = await getDb(context.env)
- .select({ url: monitors.url })
- .from(monitors)
- .where(eq(monitors.id, id))
- .limit(1);
- if (!monitor) return context.json({ message: "Monitor not found" }, 404);
+monitorRoutes.get('/:id/favicon', async (context) => {
+ const id = parseId(context.req.param('id'));
+ if (id === null) return context.json({ message: 'Monitor not found' }, 404);
+ const [monitor] = await getDb(context.env).select({ url: monitors.url }).from(monitors).where(eq(monitors.id, id)).limit(1);
+ if (!monitor) return context.json({ message: 'Monitor not found' }, 404);
const cacheKey = new Request(`${new URL(context.req.url).origin}/api/monitors/${id}/favicon`);
let cache: EdgeCache | null = null;
@@ -462,14 +458,14 @@ monitorRoutes.get("/:id/favicon", async (context) => {
}
const favicon = await resolveFavicon(monitor.url);
- if (!favicon) return context.json({ message: "No favicon" }, 404);
+ if (!favicon) return context.json({ message: 'No favicon' }, 404);
const response = new Response(favicon.body, {
headers: {
- "Cache-Control": `public, max-age=${FAVICON_CACHE_SECONDS}`,
- "Content-Length": String(favicon.body.byteLength),
- "Content-Type": favicon.contentType,
- "X-Content-Type-Options": "nosniff",
+ 'Cache-Control': `public, max-age=${FAVICON_CACHE_SECONDS}`,
+ 'Content-Length': String(favicon.body.byteLength),
+ 'Content-Type': favicon.contentType,
+ 'X-Content-Type-Options': 'nosniff',
},
});
if (cache) {
@@ -478,12 +474,12 @@ monitorRoutes.get("/:id/favicon", async (context) => {
return response;
});
-monitorRoutes.post("/", async (context) => {
+monitorRoutes.post('/', async (context) => {
let body: unknown;
try {
body = await context.req.json();
} catch {
- return context.json({ message: "Invalid request body" }, 400);
+ return context.json({ message: 'Invalid request body' }, 400);
}
const parsed = parseMonitorInput(body);
@@ -509,21 +505,21 @@ monitorRoutes.post("/", async (context) => {
return context.json({ monitor });
});
-monitorRoutes.patch("/:id", async (context) => {
- const id = parseId(context.req.param("id"));
- if (id === null) return context.json({ message: "Monitor not found" }, 404);
+monitorRoutes.patch('/:id', async (context) => {
+ const id = parseId(context.req.param('id'));
+ if (id === null) return context.json({ message: 'Monitor not found' }, 404);
let body: unknown;
try {
body = await context.req.json();
} catch {
- return context.json({ message: "Invalid request body" }, 400);
+ return context.json({ message: 'Invalid request body' }, 400);
}
const parsed = parseMonitorInput(body, true);
if (!parsed.ok) return context.json({ message: parsed.message }, 400);
if (Object.keys(parsed.value).length === 0) {
- return context.json({ message: "Provide at least one field to update" }, 400);
+ return context.json({ message: 'Provide at least one field to update' }, 400);
}
const [monitor] = await getDb(context.env)
@@ -531,38 +527,35 @@ monitorRoutes.patch("/:id", async (context) => {
.set({ ...parsed.value, updatedAt: new Date() })
.where(eq(monitors.id, id))
.returning();
- if (!monitor) return context.json({ message: "Monitor not found" }, 404);
+ if (!monitor) return context.json({ message: 'Monitor not found' }, 404);
return context.json({ monitor });
});
-monitorRoutes.delete("/:id", async (context) => {
- const id = parseId(context.req.param("id"));
- if (id === null) return context.json({ message: "Monitor not found" }, 404);
+monitorRoutes.delete('/:id', async (context) => {
+ const id = parseId(context.req.param('id'));
+ if (id === null) return context.json({ message: 'Monitor not found' }, 404);
const db = getDb(context.env);
const [monitor] = await db.select({ id: monitors.id }).from(monitors).where(eq(monitors.id, id)).limit(1);
- if (!monitor) return context.json({ message: "Monitor not found" }, 404);
+ if (!monitor) return context.json({ message: 'Monitor not found' }, 404);
- await db.batch([
- db.delete(checks).where(eq(checks.monitorId, id)),
- db.delete(monitors).where(eq(monitors.id, id)),
- ]);
+ await db.batch([db.delete(checks).where(eq(checks.monitorId, id)), db.delete(monitors).where(eq(monitors.id, id))]);
return context.json({ ok: true });
});
-monitorRoutes.post("/:id/check", async (context) => {
- const id = parseId(context.req.param("id"));
- if (id === null) return context.json({ message: "Monitor not found" }, 404);
+monitorRoutes.post('/:id/check', async (context) => {
+ const id = parseId(context.req.param('id'));
+ if (id === null) return context.json({ message: 'Monitor not found' }, 404);
const db = getDb(context.env);
const [monitor] = await db.select().from(monitors).where(eq(monitors.id, id)).limit(1);
- if (!monitor) return context.json({ message: "Monitor not found" }, 404);
+ if (!monitor) return context.json({ message: 'Monitor not found' }, 404);
const result = await runCheck(monitor);
const checkedAt = new Date();
const { statements, transition } = buildResultStatements(db, monitor, result, checkedAt);
- await db.batch(statements as [typeof statements[number], ...typeof statements]);
+ await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
if (transition) {
await sendIncidentAlert(context.env, { monitor, kind: transition, result, at: checkedAt });
}
diff --git a/src/worker/routes/settings.ts b/src/worker/routes/settings.ts
index c0186ef..cfbbc09 100644
--- a/src/worker/routes/settings.ts
+++ b/src/worker/routes/settings.ts
@@ -1,9 +1,9 @@
-import { eq } from "drizzle-orm";
-import { Hono } from "hono";
-import { getDb } from "../db/client";
-import { notificationSettings } from "../db/schema";
-import { requireAuth, type AuthVariables } from "../lib/require-auth";
-import { sendTestWebhook } from "../notifications/webhook";
+import { eq } from 'drizzle-orm';
+import { Hono } from 'hono';
+import { getDb } from '../db/client';
+import { notificationSettings } from '../db/schema';
+import { requireAuth, type AuthVariables } from '../lib/require-auth';
+import { sendTestWebhook } from '../notifications/webhook';
type NotificationInput = {
webhookUrl: string | null;
@@ -11,52 +11,48 @@ type NotificationInput = {
};
function parseNotificationInput(value: unknown): NotificationInput | string {
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
- return "Invalid request body";
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+ return 'Invalid request body';
}
const body = value as Record;
- if (typeof body.webhookEnabled !== "boolean") {
- return "webhookEnabled must be a boolean";
+ if (typeof body.webhookEnabled !== 'boolean') {
+ return 'webhookEnabled must be a boolean';
}
- const rawUrl = typeof body.webhookUrl === "string" ? body.webhookUrl.trim() : body.webhookUrl;
- if (rawUrl !== null && typeof rawUrl !== "string") return "webhookUrl must be a URL or null";
+ const rawUrl = typeof body.webhookUrl === 'string' ? body.webhookUrl.trim() : body.webhookUrl;
+ if (rawUrl !== null && typeof rawUrl !== 'string') return 'webhookUrl must be a URL or null';
let webhookUrl = rawUrl || null;
if (webhookUrl) {
try {
const url = new URL(webhookUrl);
- if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("protocol");
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('protocol');
webhookUrl = url.toString();
} catch {
- return "Enter a valid http or https webhook URL";
+ return 'Enter a valid http or https webhook URL';
}
}
- if (body.webhookEnabled && !webhookUrl) return "A webhook URL is required when alerts are enabled";
+ if (body.webhookEnabled && !webhookUrl) return 'A webhook URL is required when alerts are enabled';
return { webhookUrl, webhookEnabled: body.webhookEnabled };
}
const settingsRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
-settingsRoutes.use("*", requireAuth);
+settingsRoutes.use('*', requireAuth);
-settingsRoutes.get("/notifications", async (context) => {
- const [settings] = await getDb(context.env)
- .select()
- .from(notificationSettings)
- .where(eq(notificationSettings.id, 1))
- .limit(1);
+settingsRoutes.get('/notifications', async (context) => {
+ const [settings] = await getDb(context.env).select().from(notificationSettings).where(eq(notificationSettings.id, 1)).limit(1);
return context.json({
settings: settings ?? { id: 1, webhookUrl: null, webhookEnabled: false, createdAt: null, updatedAt: null },
});
});
-settingsRoutes.put("/notifications", async (context) => {
+settingsRoutes.put('/notifications', async (context) => {
let body: unknown;
try {
body = await context.req.json();
} catch {
- return context.json({ message: "Invalid request body" }, 400);
+ return context.json({ message: 'Invalid request body' }, 400);
}
const input = parseNotificationInput(body);
- if (typeof input === "string") return context.json({ message: input }, 400);
+ if (typeof input === 'string') return context.json({ message: input }, 400);
const db = getDb(context.env);
const now = new Date();
@@ -71,15 +67,11 @@ settingsRoutes.put("/notifications", async (context) => {
return context.json({ settings });
});
-settingsRoutes.post("/notifications/test", async (context) => {
- const [settings] = await getDb(context.env)
- .select()
- .from(notificationSettings)
- .where(eq(notificationSettings.id, 1))
- .limit(1);
- if (!settings?.webhookUrl) return context.json({ message: "Save a webhook URL first" }, 400);
+settingsRoutes.post('/notifications/test', async (context) => {
+ const [settings] = await getDb(context.env).select().from(notificationSettings).where(eq(notificationSettings.id, 1)).limit(1);
+ if (!settings?.webhookUrl) return context.json({ message: 'Save a webhook URL first' }, 400);
const delivered = await sendTestWebhook(settings.webhookUrl);
- if (!delivered) return context.json({ message: "Webhook delivery failed" }, 502);
+ if (!delivered) return context.json({ message: 'Webhook delivery failed' }, 502);
return context.json({ ok: true });
});
diff --git a/src/worker/routes/status.ts b/src/worker/routes/status.ts
index 7cb79da..7ba444c 100644
--- a/src/worker/routes/status.ts
+++ b/src/worker/routes/status.ts
@@ -1,14 +1,14 @@
-import { and, eq, gte, inArray, lt, sql } from "drizzle-orm";
-import { Hono } from "hono";
-import { getDb } from "../db/client";
-import { checks, monitorDailyStats, monitors } from "../db/schema";
-import { resolveFavicon } from "./monitors";
+import { and, eq, gte, inArray, lt, sql } from 'drizzle-orm';
+import { Hono } from 'hono';
+import { getDb } from '../db/client';
+import { checks, monitorDailyStats, monitors } from '../db/schema';
+import { resolveFavicon } from './monitors';
const DAY_MS = 24 * 60 * 60 * 1000;
const FAVICON_CACHE_SECONDS = 86_400;
-type ServiceStatus = "up" | "down" | "unknown";
-type OverallStatus = "operational" | "degraded" | "down";
+type ServiceStatus = 'up' | 'down' | 'unknown';
+type OverallStatus = 'operational' | 'degraded' | 'down';
type HistoryEntry = {
day: number;
@@ -37,27 +37,27 @@ function roundUptime(upChecks: number, totalChecks: number) {
}
function serviceStatus(lastOk: boolean | null): ServiceStatus {
- if (lastOk === true) return "up";
- if (lastOk === false) return "down";
- return "unknown";
+ if (lastOk === true) return 'up';
+ if (lastOk === false) return 'down';
+ return 'unknown';
}
function overallStatus(statuses: ServiceStatus[]): OverallStatus {
let checked = 0;
let down = 0;
for (const status of statuses) {
- if (status === "unknown") continue;
+ if (status === 'unknown') continue;
checked += 1;
- if (status === "down") down += 1;
+ if (status === 'down') down += 1;
}
- if (down === 0) return "operational";
- if (down === checked) return "down";
- return "degraded";
+ if (down === 0) return 'operational';
+ if (down === checked) return 'down';
+ return 'degraded';
}
const statusRoutes = new Hono<{ Bindings: Env }>();
-statusRoutes.get("/", async (context) => {
+statusRoutes.get('/', async (context) => {
const db = getDb(context.env);
const monitorRows = await db
.select({
@@ -79,30 +79,31 @@ statusRoutes.get("/", async (context) => {
if (monitorIds.length > 0) {
[historicalRows, todayRows] = await Promise.all([
- db.select({
- monitorId: monitorDailyStats.monitorId,
- day: monitorDailyStats.day,
- totalChecks: monitorDailyStats.totalChecks,
- upChecks: monitorDailyStats.upChecks,
- })
+ db
+ .select({
+ monitorId: monitorDailyStats.monitorId,
+ day: monitorDailyStats.day,
+ totalChecks: monitorDailyStats.totalChecks,
+ upChecks: monitorDailyStats.upChecks,
+ })
.from(monitorDailyStats)
- .where(and(
- inArray(monitorDailyStats.monitorId, monitorIds),
- gte(monitorDailyStats.day, new Date(cutoff)),
- lt(monitorDailyStats.day, new Date(today)),
- ))
+ .where(
+ and(
+ inArray(monitorDailyStats.monitorId, monitorIds),
+ gte(monitorDailyStats.day, new Date(cutoff)),
+ lt(monitorDailyStats.day, new Date(today)),
+ ),
+ )
.orderBy(monitorDailyStats.day),
- db.select({
- monitorId: checks.monitorId,
- day: sql`cast(${today} as integer)`,
- totalChecks: sql`count(*)`,
- upChecks: sql`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
- })
+ db
+ .select({
+ monitorId: checks.monitorId,
+ day: sql`cast(${today} as integer)`,
+ totalChecks: sql`count(*)`,
+ upChecks: sql`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
+ })
.from(checks)
- .where(and(
- inArray(checks.monitorId, monitorIds),
- gte(checks.checkedAt, new Date(today)),
- ))
+ .where(and(inArray(checks.monitorId, monitorIds), gte(checks.checkedAt, new Date(today))))
.groupBy(checks.monitorId),
]);
}
@@ -144,15 +145,15 @@ statusRoutes.get("/", async (context) => {
});
});
-statusRoutes.get("/:id/favicon", async (context) => {
- const id = parseId(context.req.param("id"));
- if (id === null) return context.json({ message: "Service not found" }, 404);
+statusRoutes.get('/:id/favicon', async (context) => {
+ const id = parseId(context.req.param('id'));
+ if (id === null) return context.json({ message: 'Service not found' }, 404);
const [monitor] = await getDb(context.env)
.select({ url: monitors.url })
.from(monitors)
.where(and(eq(monitors.id, id), eq(monitors.enabled, true)))
.limit(1);
- if (!monitor) return context.json({ message: "Service not found" }, 404);
+ if (!monitor) return context.json({ message: 'Service not found' }, 404);
const cacheKey = new Request(`${new URL(context.req.url).origin}/api/status/${id}/favicon`);
let cache: EdgeCache | null = null;
@@ -166,14 +167,14 @@ statusRoutes.get("/:id/favicon", async (context) => {
}
const favicon = await resolveFavicon(monitor.url);
- if (!favicon) return context.json({ message: "No favicon" }, 404);
+ if (!favicon) return context.json({ message: 'No favicon' }, 404);
const response = new Response(favicon.body, {
headers: {
- "Cache-Control": `public, max-age=${FAVICON_CACHE_SECONDS}`,
- "Content-Length": String(favicon.body.byteLength),
- "Content-Type": favicon.contentType,
- "X-Content-Type-Options": "nosniff",
+ 'Cache-Control': `public, max-age=${FAVICON_CACHE_SECONDS}`,
+ 'Content-Length': String(favicon.body.byteLength),
+ 'Content-Type': favicon.contentType,
+ 'X-Content-Type-Options': 'nosniff',
},
});
if (cache) {
diff --git a/src/worker/scheduled/cleanup.ts b/src/worker/scheduled/cleanup.ts
index c247e85..3842b6a 100644
--- a/src/worker/scheduled/cleanup.ts
+++ b/src/worker/scheduled/cleanup.ts
@@ -1,6 +1,6 @@
-import { lt } from "drizzle-orm";
-import { getDb } from "../db/client";
-import { checks, loginAttempts, monitorDailyStats, sessions } from "../db/schema";
+import { lt } from 'drizzle-orm';
+import { getDb } from '../db/client';
+import { checks, loginAttempts, monitorDailyStats, sessions } from '../db/schema';
const LOGIN_ATTEMPT_RETENTION_MS = 60 * 60 * 1000;
const CHECK_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
@@ -12,29 +12,8 @@ export async function cleanupExpiredAuthRecords(env: Env) {
await db.batch([
db.delete(sessions).where(lt(sessions.expiresAt, now)),
- db
- .delete(loginAttempts)
- .where(
- lt(
- loginAttempts.attemptedAt,
- new Date(now.getTime() - LOGIN_ATTEMPT_RETENTION_MS),
- ),
- ),
- db
- .delete(checks)
- .where(
- lt(
- checks.checkedAt,
- new Date(now.getTime() - CHECK_RETENTION_MS),
- ),
- ),
- db
- .delete(monitorDailyStats)
- .where(
- lt(
- monitorDailyStats.day,
- new Date(now.getTime() - DAILY_STATS_RETENTION_MS),
- ),
- ),
+ db.delete(loginAttempts).where(lt(loginAttempts.attemptedAt, new Date(now.getTime() - LOGIN_ATTEMPT_RETENTION_MS))),
+ db.delete(checks).where(lt(checks.checkedAt, new Date(now.getTime() - CHECK_RETENTION_MS))),
+ db.delete(monitorDailyStats).where(lt(monitorDailyStats.day, new Date(now.getTime() - DAILY_STATS_RETENTION_MS))),
]);
}
diff --git a/src/worker/scheduled/rollup.ts b/src/worker/scheduled/rollup.ts
index 786c08e..e0cb2f0 100644
--- a/src/worker/scheduled/rollup.ts
+++ b/src/worker/scheduled/rollup.ts
@@ -1,16 +1,13 @@
-import { and, gte, lt, sql } from "drizzle-orm";
-import { getDb } from "../db/client";
-import { checks, monitorDailyStats } from "../db/schema";
+import { and, gte, lt, sql } from 'drizzle-orm';
+import { getDb } from '../db/client';
+import { checks, monitorDailyStats } from '../db/schema';
export type DailyRollupSummary = {
day: string;
monitors: number;
};
-export async function runDailyRollup(
- env: Env,
- now = new Date(),
-): Promise {
+export async function runDailyRollup(env: Env, now = new Date()): Promise {
const db = getDb(env);
const currentUtcDay = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const dayStart = new Date(currentUtcDay - 24 * 60 * 60 * 1000);
@@ -30,28 +27,30 @@ export async function runDailyRollup(
.groupBy(checks.monitorId);
if (rows.length > 0) {
- const statements = rows.map((row) => db
- .insert(monitorDailyStats)
- .values({
- monitorId: row.monitorId,
- day: dayStart,
- totalChecks: row.totalChecks,
- upChecks: row.upChecks,
- avgLatencyMs: row.avgLatencyMs,
- minLatencyMs: row.minLatencyMs,
- maxLatencyMs: row.maxLatencyMs,
- })
- .onConflictDoUpdate({
- target: [monitorDailyStats.monitorId, monitorDailyStats.day],
- set: {
+ const statements = rows.map((row) =>
+ db
+ .insert(monitorDailyStats)
+ .values({
+ monitorId: row.monitorId,
+ day: dayStart,
totalChecks: row.totalChecks,
upChecks: row.upChecks,
avgLatencyMs: row.avgLatencyMs,
minLatencyMs: row.minLatencyMs,
maxLatencyMs: row.maxLatencyMs,
- },
- }));
- await db.batch(statements as [typeof statements[number], ...typeof statements]);
+ })
+ .onConflictDoUpdate({
+ target: [monitorDailyStats.monitorId, monitorDailyStats.day],
+ set: {
+ totalChecks: row.totalChecks,
+ upChecks: row.upChecks,
+ avgLatencyMs: row.avgLatencyMs,
+ minLatencyMs: row.minLatencyMs,
+ maxLatencyMs: row.maxLatencyMs,
+ },
+ }),
+ );
+ await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
}
return { day: dayStart.toISOString().slice(0, 10), monitors: rows.length };
diff --git a/test/auth.spec.ts b/test/auth.spec.ts
index 688124e..5ec2f7b 100644
--- a/test/auth.spec.ts
+++ b/test/auth.spec.ts
@@ -1,82 +1,76 @@
-import { applyD1Migrations, env, SELF, type D1Migration } from "cloudflare:test";
-import { beforeAll, beforeEach, describe, expect, it } from "vitest";
-import { hashPassword } from "../src/worker/lib/password";
+import { applyD1Migrations, env, SELF, type D1Migration } from 'cloudflare:test';
+import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
+import { hashPassword } from '../src/worker/lib/password';
-const ADMIN_PASSWORD = "correct-horse-battery-staple";
+const ADMIN_PASSWORD = 'correct-horse-battery-staple';
async function seedAdmin() {
await env.DB.batch([
- env.DB.prepare("DELETE FROM login_attempts"),
- env.DB.prepare("DELETE FROM sessions"),
- env.DB.prepare("DELETE FROM admin_credentials"),
+ env.DB.prepare('DELETE FROM login_attempts'),
+ env.DB.prepare('DELETE FROM sessions'),
+ env.DB.prepare('DELETE FROM admin_credentials'),
]);
const now = Date.now();
- await env.DB.prepare(
- "INSERT INTO admin_credentials (id, password_hash, created_at, updated_at) VALUES (1, ?, ?, ?)",
- )
- .bind(
- await hashPassword(ADMIN_PASSWORD),
- now,
- now,
- )
+ await env.DB.prepare('INSERT INTO admin_credentials (id, password_hash, created_at, updated_at) VALUES (1, ?, ?, ?)')
+ .bind(await hashPassword(ADMIN_PASSWORD), now, now)
.run();
}
-function login(password = ADMIN_PASSWORD, ipAddress = "198.51.100.10") {
- return SELF.fetch("https://example.com/api/auth/login", {
- method: "POST",
+function login(password = ADMIN_PASSWORD, ipAddress = '198.51.100.10') {
+ return SELF.fetch('https://example.com/api/auth/login', {
+ method: 'POST',
headers: {
- "Content-Type": "application/json",
- "CF-Connecting-IP": ipAddress,
- Origin: "https://example.com",
+ 'Content-Type': 'application/json',
+ 'CF-Connecting-IP': ipAddress,
+ Origin: 'https://example.com',
},
body: JSON.stringify({ password }),
});
}
function cookieFrom(response: Response) {
- return response.headers.get("Set-Cookie")?.split(";", 1)[0] ?? "";
+ return response.headers.get('Set-Cookie')?.split(';', 1)[0] ?? '';
}
-describe("authentication", () => {
+describe('authentication', () => {
beforeAll(async () => {
const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
});
beforeEach(seedAdmin);
- it("logs in with the admin password and creates an HttpOnly session", async () => {
+ it('logs in with the admin password and creates an HttpOnly session', async () => {
const response = await login();
const body = await response.json<{ authenticated: boolean }>();
expect(response.status).toBe(200);
- expect(response.headers.get("Set-Cookie")).toContain("upwatch_session=");
- expect(response.headers.get("Set-Cookie")).toContain("HttpOnly");
- expect(response.headers.get("Set-Cookie")).toContain("SameSite=Lax");
+ expect(response.headers.get('Set-Cookie')).toContain('upwatch_session=');
+ expect(response.headers.get('Set-Cookie')).toContain('HttpOnly');
+ expect(response.headers.get('Set-Cookie')).toContain('SameSite=Lax');
expect(body).toEqual({ authenticated: true });
- const session = await env.DB.prepare("SELECT id FROM sessions").first();
+ const session = await env.DB.prepare('SELECT id FROM sessions').first();
expect(session).not.toBeNull();
});
- it("rejects an incorrect password without setting a cookie", async () => {
- const response = await login("incorrect-password");
+ it('rejects an incorrect password without setting a cookie', async () => {
+ const response = await login('incorrect-password');
expect(response.status).toBe(401);
- expect(response.headers.get("Set-Cookie")).toBeNull();
+ expect(response.headers.get('Set-Cookie')).toBeNull();
expect(await response.json()).toEqual({
- message: "Password is incorrect",
+ message: 'Password is incorrect',
});
});
- it("returns authentication state from the session endpoint", async () => {
- const anonymousResponse = await SELF.fetch("https://example.com/api/auth/me");
+ it('returns authentication state from the session endpoint', async () => {
+ const anonymousResponse = await SELF.fetch('https://example.com/api/auth/me');
expect(anonymousResponse.status).toBe(200);
expect(await anonymousResponse.json()).toEqual({ authenticated: false });
const loginResponse = await login();
- const authenticatedResponse = await SELF.fetch("https://example.com/api/auth/me", {
+ const authenticatedResponse = await SELF.fetch('https://example.com/api/auth/me', {
headers: { Cookie: cookieFrom(loginResponse) },
});
@@ -84,39 +78,38 @@ describe("authentication", () => {
expect(await authenticatedResponse.json()).toEqual({ authenticated: true });
});
- it("revokes the persisted session on logout", async () => {
+ it('revokes the persisted session on logout', async () => {
const loginResponse = await login();
const cookie = cookieFrom(loginResponse);
- const logoutResponse = await SELF.fetch("https://example.com/api/auth/logout", {
- method: "POST",
+ const logoutResponse = await SELF.fetch('https://example.com/api/auth/logout', {
+ method: 'POST',
headers: {
Cookie: cookie,
- Origin: "https://example.com",
+ Origin: 'https://example.com',
},
});
expect(logoutResponse.status).toBe(200);
expect(await logoutResponse.json()).toEqual({ ok: true });
- const sessionCount = await env.DB.prepare("SELECT COUNT(*) AS count FROM sessions")
- .first<{ count: number }>();
+ const sessionCount = await env.DB.prepare('SELECT COUNT(*) AS count FROM sessions').first<{ count: number }>();
expect(sessionCount?.count).toBe(0);
- const sessionResponse = await SELF.fetch("https://example.com/api/auth/me", {
+ const sessionResponse = await SELF.fetch('https://example.com/api/auth/me', {
headers: { Cookie: cookie },
});
expect(await sessionResponse.json()).toEqual({ authenticated: false });
});
- it("rate limits repeated failed login attempts by IP", async () => {
+ it('rate limits repeated failed login attempts by IP', async () => {
const responses: Response[] = [];
for (let attempt = 0; attempt < 11; attempt += 1) {
- responses.push(await login("incorrect-password", "203.0.113.42"));
+ responses.push(await login('incorrect-password', '203.0.113.42'));
}
expect(responses.slice(0, 10).every((response) => response.status === 401)).toBe(true);
expect(responses[10].status).toBe(429);
expect(await responses[10].json()).toEqual({
- message: "Too many login attempts. Try again later",
+ message: 'Too many login attempts. Try again later',
});
});
});
diff --git a/test/checks.spec.ts b/test/checks.spec.ts
index 9c6a9ca..c367ebc 100644
--- a/test/checks.spec.ts
+++ b/test/checks.spec.ts
@@ -1,23 +1,23 @@
-import { applyD1Migrations, env, type D1Migration } from "cloudflare:test";
-import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
-import { runDueChecks } from "../src/worker/checks/run-due-checks";
+import { applyD1Migrations, env, type D1Migration } from 'cloudflare:test';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { runDueChecks } from '../src/worker/checks/run-due-checks';
async function clearMonitoringTables() {
await env.DB.batch([
- env.DB.prepare("DELETE FROM checks"),
- env.DB.prepare("DELETE FROM incidents"),
- env.DB.prepare("DELETE FROM monitor_daily_stats"),
- env.DB.prepare("DELETE FROM notification_settings"),
- env.DB.prepare("DELETE FROM monitors"),
+ env.DB.prepare('DELETE FROM checks'),
+ env.DB.prepare('DELETE FROM incidents'),
+ env.DB.prepare('DELETE FROM monitor_daily_stats'),
+ env.DB.prepare('DELETE FROM notification_settings'),
+ env.DB.prepare('DELETE FROM monitors'),
]);
}
async function insertMonitor(overrides: Record = {}) {
const now = Date.now();
const values = {
- name: "Example",
- url: "https://example.com/health",
- method: "GET",
+ name: 'Example',
+ url: 'https://example.com/health',
+ method: 'GET',
expected_status: 200,
interval_seconds: 300,
timeout_ms: 10_000,
@@ -27,11 +27,13 @@ async function insertMonitor(overrides: Record = {}) {
updated_at: now,
...overrides,
};
- const result = await env.DB.prepare(`
+ const result = await env.DB.prepare(
+ `
INSERT INTO monitors
(name, url, method, expected_status, interval_seconds, timeout_ms, enabled, alerts_enabled, last_ok, last_checked_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
- `)
+ `,
+ )
.bind(
values.name,
values.url,
@@ -49,7 +51,7 @@ async function insertMonitor(overrides: Record = {}) {
return Number(result.meta.last_row_id);
}
-describe("scheduled monitor checks", () => {
+describe('scheduled monitor checks', () => {
beforeAll(async () => {
const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
@@ -57,83 +59,97 @@ describe("scheduled monitor checks", () => {
beforeEach(clearMonitoringTables);
afterEach(() => vi.unstubAllGlobals());
- it("records a successful check and updates the monitor snapshot", async () => {
+ it('records a successful check and updates the monitor snapshot', async () => {
const id = await insertMonitor();
- vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 200 })));
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response(null, { status: 200 })),
+ );
const summary = await runDueChecks(env);
expect(summary).toEqual({ checked: 1, up: 1, down: 0 });
- const monitor = await env.DB.prepare("SELECT last_ok, last_status_code, last_latency_ms, last_checked_at FROM monitors WHERE id = ?")
+ const monitor = await env.DB.prepare('SELECT last_ok, last_status_code, last_latency_ms, last_checked_at FROM monitors WHERE id = ?')
.bind(id)
.first<{ last_ok: number; last_status_code: number; last_latency_ms: number; last_checked_at: number }>();
expect(monitor).toMatchObject({ last_ok: 1, last_status_code: 200 });
expect(monitor?.last_latency_ms).toEqual(expect.any(Number));
expect(monitor?.last_checked_at).toEqual(expect.any(Number));
- const checkCount = await env.DB.prepare("SELECT COUNT(*) AS count FROM checks WHERE monitor_id = ?")
+ const checkCount = await env.DB.prepare('SELECT COUNT(*) AS count FROM checks WHERE monitor_id = ?')
.bind(id)
.first<{ count: number }>();
expect(checkCount?.count).toBe(1);
});
- it("records a mismatched status as down with a useful error", async () => {
+ it('records a mismatched status as down with a useful error', async () => {
const id = await insertMonitor();
- vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 500 })));
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response(null, { status: 500 })),
+ );
const summary = await runDueChecks(env);
expect(summary).toEqual({ checked: 1, up: 0, down: 1 });
- const monitor = await env.DB.prepare("SELECT last_ok, last_status_code, last_error FROM monitors WHERE id = ?")
+ const monitor = await env.DB.prepare('SELECT last_ok, last_status_code, last_error FROM monitors WHERE id = ?')
.bind(id)
.first<{ last_ok: number; last_status_code: number; last_error: string }>();
expect(monitor).toEqual({
last_ok: 0,
last_status_code: 500,
- last_error: "Expected HTTP 200, received 500",
+ last_error: 'Expected HTTP 200, received 500',
});
- const incident = await env.DB.prepare("SELECT monitor_id, resolved_at, start_status_code, start_error FROM incidents WHERE monitor_id = ?")
+ const incident = await env.DB.prepare(
+ 'SELECT monitor_id, resolved_at, start_status_code, start_error FROM incidents WHERE monitor_id = ?',
+ )
.bind(id)
.first<{ monitor_id: number; resolved_at: number | null; start_status_code: number; start_error: string }>();
expect(incident).toEqual({
monitor_id: id,
resolved_at: null,
start_status_code: 500,
- start_error: "Expected HTTP 200, received 500",
+ start_error: 'Expected HTTP 200, received 500',
});
});
- it("does not open duplicate incidents while a monitor stays down", async () => {
+ it('does not open duplicate incidents while a monitor stays down', async () => {
const id = await insertMonitor({ last_ok: 0 });
- vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 503 })));
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response(null, { status: 503 })),
+ );
await runDueChecks(env);
- const count = await env.DB.prepare("SELECT COUNT(*) AS count FROM incidents WHERE monitor_id = ?")
- .bind(id)
- .first<{ count: number }>();
+ const count = await env.DB.prepare('SELECT COUNT(*) AS count FROM incidents WHERE monitor_id = ?').bind(id).first<{ count: number }>();
expect(count?.count).toBe(0);
});
- it("resolves the open incident on recovery", async () => {
+ it('resolves the open incident on recovery', async () => {
const id = await insertMonitor({ last_ok: 0 });
const startedAt = Date.now() - 60_000;
- await env.DB.prepare("INSERT INTO incidents (monitor_id, started_at, start_status_code, start_error, created_at, updated_at) VALUES (?, ?, 500, 'Down', ?, ?)")
+ await env.DB.prepare(
+ "INSERT INTO incidents (monitor_id, started_at, start_status_code, start_error, created_at, updated_at) VALUES (?, ?, 500, 'Down', ?, ?)",
+ )
.bind(id, startedAt, startedAt, startedAt)
.run();
- vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 200 })));
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response(null, { status: 200 })),
+ );
await runDueChecks(env);
- const incident = await env.DB.prepare("SELECT resolved_at, duration_ms FROM incidents WHERE monitor_id = ?")
+ const incident = await env.DB.prepare('SELECT resolved_at, duration_ms FROM incidents WHERE monitor_id = ?')
.bind(id)
.first<{ resolved_at: number | null; duration_ms: number | null }>();
expect(incident?.resolved_at).toEqual(expect.any(Number));
expect(incident?.duration_ms).toBeGreaterThanOrEqual(60_000);
});
- it("skips disabled and not-yet-due monitors", async () => {
- await insertMonitor({ name: "Disabled", enabled: 0 });
- await insertMonitor({ name: "Recent", last_checked_at: Date.now() });
+ it('skips disabled and not-yet-due monitors', async () => {
+ await insertMonitor({ name: 'Disabled', enabled: 0 });
+ await insertMonitor({ name: 'Recent', last_checked_at: Date.now() });
const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
- vi.stubGlobal("fetch", fetchMock);
+ vi.stubGlobal('fetch', fetchMock);
const summary = await runDueChecks(env);
expect(summary).toEqual({ checked: 0, up: 0, down: 0 });
diff --git a/test/index.spec.ts b/test/index.spec.ts
index 636d32a..7405e57 100644
--- a/test/index.spec.ts
+++ b/test/index.spec.ts
@@ -1,9 +1,9 @@
-import { SELF } from "cloudflare:test";
-import { describe, expect, it } from "vitest";
+import { SELF } from 'cloudflare:test';
+import { describe, expect, it } from 'vitest';
-describe("uptime monitoring Worker", () => {
- it("returns a successful D1 health response", async () => {
- const response = await SELF.fetch("https://example.com/api/health");
+describe('uptime monitoring Worker', () => {
+ it('returns a successful D1 health response', async () => {
+ const response = await SELF.fetch('https://example.com/api/health');
const body = await response.json<{
ok: boolean;
db: { ok: number } | null;
@@ -11,7 +11,7 @@ describe("uptime monitoring Worker", () => {
}>();
expect(response.status).toBe(200);
- expect(response.headers.get("content-type")).toContain("application/json");
+ expect(response.headers.get('content-type')).toContain('application/json');
expect(body.ok).toBe(true);
expect(body.db).toEqual({ ok: 1 });
expect(body.ts).toEqual(expect.any(Number));
diff --git a/test/monitors.spec.ts b/test/monitors.spec.ts
index 054490b..425f3e3 100644
--- a/test/monitors.spec.ts
+++ b/test/monitors.spec.ts
@@ -1,13 +1,13 @@
-import { applyD1Migrations, env, SELF, type D1Migration } from "cloudflare:test";
-import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
-import { hashPassword } from "../src/worker/lib/password";
-import { resolveFavicon } from "../src/worker/routes/monitors";
+import { applyD1Migrations, env, SELF, type D1Migration } from 'cloudflare:test';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { hashPassword } from '../src/worker/lib/password';
+import { resolveFavicon } from '../src/worker/routes/monitors';
-const ADMIN_PASSWORD = "correct-horse-battery-staple";
+const ADMIN_PASSWORD = 'correct-horse-battery-staple';
const VALID_MONITOR = {
- name: "Example",
- url: "https://example.com/health",
- method: "GET",
+ name: 'Example',
+ url: 'https://example.com/health',
+ method: 'GET',
expectedStatus: 200,
intervalSeconds: 300,
timeoutMs: 10_000,
@@ -15,51 +15,51 @@ const VALID_MONITOR = {
async function seedAdmin() {
await env.DB.batch([
- env.DB.prepare("DELETE FROM checks"),
- env.DB.prepare("DELETE FROM incidents"),
- env.DB.prepare("DELETE FROM monitor_daily_stats"),
- env.DB.prepare("DELETE FROM notification_settings"),
- env.DB.prepare("DELETE FROM monitors"),
- env.DB.prepare("DELETE FROM login_attempts"),
- env.DB.prepare("DELETE FROM sessions"),
- env.DB.prepare("DELETE FROM admin_credentials"),
+ env.DB.prepare('DELETE FROM checks'),
+ env.DB.prepare('DELETE FROM incidents'),
+ env.DB.prepare('DELETE FROM monitor_daily_stats'),
+ env.DB.prepare('DELETE FROM notification_settings'),
+ env.DB.prepare('DELETE FROM monitors'),
+ env.DB.prepare('DELETE FROM login_attempts'),
+ env.DB.prepare('DELETE FROM sessions'),
+ env.DB.prepare('DELETE FROM admin_credentials'),
]);
const now = Date.now();
- await env.DB.prepare("INSERT INTO admin_credentials (id, password_hash, created_at, updated_at) VALUES (1, ?, ?, ?)")
+ await env.DB.prepare('INSERT INTO admin_credentials (id, password_hash, created_at, updated_at) VALUES (1, ?, ?, ?)')
.bind(await hashPassword(ADMIN_PASSWORD), now, now)
.run();
}
async function authenticatedCookie() {
- const response = await SELF.fetch("https://example.com/api/auth/login", {
- method: "POST",
+ const response = await SELF.fetch('https://example.com/api/auth/login', {
+ method: 'POST',
headers: {
- "Content-Type": "application/json",
- "CF-Connecting-IP": "198.51.100.20",
- Origin: "https://example.com",
+ 'Content-Type': 'application/json',
+ 'CF-Connecting-IP': '198.51.100.20',
+ Origin: 'https://example.com',
},
body: JSON.stringify({ password: ADMIN_PASSWORD }),
});
- return response.headers.get("Set-Cookie")?.split(";", 1)[0] ?? "";
+ return response.headers.get('Set-Cookie')?.split(';', 1)[0] ?? '';
}
-function apiFetch(path: string, method = "GET", cookie = "", body?: unknown) {
+function apiFetch(path: string, method = 'GET', cookie = '', body?: unknown) {
return SELF.fetch(`https://example.com${path}`, {
method,
headers: {
...(cookie ? { Cookie: cookie } : {}),
- ...(method !== "GET" ? { Origin: "https://example.com" } : {}),
- ...(body === undefined ? {} : { "Content-Type": "application/json" }),
+ ...(method !== 'GET' ? { Origin: 'https://example.com' } : {}),
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
}
async function createMonitor(cookie: string, overrides: Record = {}) {
- return apiFetch("/api/monitors", "POST", cookie, { ...VALID_MONITOR, ...overrides });
+ return apiFetch('/api/monitors', 'POST', cookie, { ...VALID_MONITOR, ...overrides });
}
-describe("monitor API", () => {
+describe('monitor API', () => {
beforeAll(async () => {
const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
@@ -67,153 +67,177 @@ describe("monitor API", () => {
beforeEach(seedAdmin);
afterEach(() => vi.unstubAllGlobals());
- it("protects every monitor endpoint, including the collection path without a trailing slash", async () => {
+ it('protects every monitor endpoint, including the collection path without a trailing slash', async () => {
const requests = [
- apiFetch("/api/monitors"),
- apiFetch("/api/monitors/1/favicon"),
- apiFetch("/api/monitors", "POST", "", VALID_MONITOR),
- apiFetch("/api/monitors/1", "PATCH", "", { name: "Changed" }),
- apiFetch("/api/monitors/1", "DELETE"),
- apiFetch("/api/monitors/1/check", "POST"),
+ apiFetch('/api/monitors'),
+ apiFetch('/api/monitors/1/favicon'),
+ apiFetch('/api/monitors', 'POST', '', VALID_MONITOR),
+ apiFetch('/api/monitors/1', 'PATCH', '', { name: 'Changed' }),
+ apiFetch('/api/monitors/1', 'DELETE'),
+ apiFetch('/api/monitors/1/check', 'POST'),
];
const responses = await Promise.all(requests);
for (const response of responses) {
expect(response.status).toBe(401);
- expect(await response.json()).toEqual({ message: "Authentication required" });
+ expect(await response.json()).toEqual({ message: 'Authentication required' });
}
});
- it("proxies and caches a monitor favicon response", async () => {
+ it('proxies and caches a monitor favicon response', async () => {
const cookie = await authenticatedCookie();
- const created = await (await createMonitor(cookie, { url: "https://favicon-route.example.test/health" }))
- .json<{ monitor: { id: number } }>();
- vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
- const url = new URL(input.toString());
- expect(url.href).toBe("https://favicon-route.example.test/favicon.ico");
- return new Response(new Uint8Array([0, 0, 1, 0]), {
- headers: { "Content-Type": "image/x-icon" },
- });
- }));
+ const created = await (
+ await createMonitor(cookie, { url: 'https://favicon-route.example.test/health' })
+ ).json<{ monitor: { id: number } }>();
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = new URL(input.toString());
+ expect(url.href).toBe('https://favicon-route.example.test/favicon.ico');
+ return new Response(new Uint8Array([0, 0, 1, 0]), {
+ headers: { 'Content-Type': 'image/x-icon' },
+ });
+ }),
+ );
- const response = await apiFetch(`/api/monitors/${created.monitor.id}/favicon`, "GET", cookie);
+ const response = await apiFetch(`/api/monitors/${created.monitor.id}/favicon`, 'GET', cookie);
expect(response.status).toBe(200);
- expect(response.headers.get("Content-Type")).toBe("image/x-icon");
- expect(response.headers.get("Cache-Control")).toBe("public, max-age=86400");
- expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
+ expect(response.headers.get('Content-Type')).toBe('image/x-icon');
+ expect(response.headers.get('Cache-Control')).toBe('public, max-age=86400');
+ expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff');
expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array([0, 0, 1, 0]));
});
- it("discovers a favicon declared in the website head", async () => {
+ it('discovers a favicon declared in the website head', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(input.toString());
- if (url.pathname === "/favicon.ico") return new Response(null, { status: 404 });
- if (url.pathname === "/") {
+ if (url.pathname === '/favicon.ico') return new Response(null, { status: 404 });
+ if (url.pathname === '/') {
return new Response(' ', {
- headers: { "Content-Type": "text/html; charset=utf-8" },
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}
return new Response(new Uint8Array([137, 80, 78, 71]), {
- headers: { "Content-Type": "image/png" },
+ headers: { 'Content-Type': 'image/png' },
});
});
- vi.stubGlobal("fetch", fetchMock);
+ vi.stubGlobal('fetch', fetchMock);
- const favicon = await resolveFavicon("https://favicon-head.example.test/status");
- expect(favicon?.contentType).toBe("image/png");
+ const favicon = await resolveFavicon('https://favicon-head.example.test/status');
+ expect(favicon?.contentType).toBe('image/png');
expect(new Uint8Array(favicon?.body ?? new ArrayBuffer(0))).toEqual(new Uint8Array([137, 80, 78, 71]));
expect(fetchMock).toHaveBeenCalledTimes(3);
});
- it("does not fetch favicons from private network hosts", async () => {
+ it('does not fetch favicons from private network hosts', async () => {
const fetchMock = vi.fn();
- vi.stubGlobal("fetch", fetchMock);
+ vi.stubGlobal('fetch', fetchMock);
- await expect(resolveFavicon("http://127.0.0.1/admin")).resolves.toBeNull();
- await expect(resolveFavicon("http://[::1]/admin")).resolves.toBeNull();
- await expect(resolveFavicon("http://10.0.0.1/admin")).resolves.toBeNull();
+ await expect(resolveFavicon('http://127.0.0.1/admin')).resolves.toBeNull();
+ await expect(resolveFavicon('http://[::1]/admin')).resolves.toBeNull();
+ await expect(resolveFavicon('http://10.0.0.1/admin')).resolves.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
- it("creates a valid monitor and returns it in the list", async () => {
+ it('creates a valid monitor and returns it in the list', async () => {
const cookie = await authenticatedCookie();
const response = await createMonitor(cookie);
const created = await response.json<{ monitor: { id: number; name: string; enabled: boolean; alertsEnabled: boolean } }>();
expect(response.status).toBe(200);
- expect(created.monitor).toMatchObject({ name: "Example", enabled: true, alertsEnabled: true });
+ expect(created.monitor).toMatchObject({ name: 'Example', enabled: true, alertsEnabled: true });
- const listResponse = await apiFetch("/api/monitors", "GET", cookie);
+ const listResponse = await apiFetch('/api/monitors', 'GET', cookie);
const list = await listResponse.json<{ monitors: Array<{ id: number; url: string }> }>();
expect(list.monitors).toHaveLength(1);
- expect(list.monitors[0]).toMatchObject({ id: created.monitor.id, url: "https://example.com/health" });
+ expect(list.monitors[0]).toMatchObject({ id: created.monitor.id, url: 'https://example.com/health' });
});
it.each([
- [{ url: "file:///etc/passwd" }, "Enter a valid http or https URL"],
- [{ intervalSeconds: 60 }, "intervalSeconds must be an integer between 300 and 86400"],
- [{ expectedStatus: 99 }, "expectedStatus must be an integer between 100 and 599"],
- ] as const)("rejects invalid monitor input %o", async (overrides, message) => {
+ [{ url: 'file:///etc/passwd' }, 'Enter a valid http or https URL'],
+ [{ intervalSeconds: 60 }, 'intervalSeconds must be an integer between 300 and 86400'],
+ [{ expectedStatus: 99 }, 'expectedStatus must be an integer between 100 and 599'],
+ ] as const)('rejects invalid monitor input %o', async (overrides, message) => {
const response = await createMonitor(await authenticatedCookie(), overrides);
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ message });
});
- it("patches only the requested fields", async () => {
+ it('patches only the requested fields', async () => {
const cookie = await authenticatedCookie();
const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>();
- const response = await apiFetch(`/api/monitors/${created.monitor.id}`, "PATCH", cookie, {
- name: "Renamed endpoint",
+ const response = await apiFetch(`/api/monitors/${created.monitor.id}`, 'PATCH', cookie, {
+ name: 'Renamed endpoint',
enabled: false,
});
const body = await response.json<{ monitor: { name: string; url: string; enabled: boolean } }>();
expect(response.status).toBe(200);
expect(body.monitor).toMatchObject({
- name: "Renamed endpoint",
- url: "https://example.com/health",
+ name: 'Renamed endpoint',
+ url: 'https://example.com/health',
enabled: false,
});
});
- it("returns detail, checks, incidents, and raw stats", async () => {
+ it('returns detail, checks, incidents, and raw stats', async () => {
const cookie = await authenticatedCookie();
const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>();
const id = created.monitor.id;
const now = Date.now();
await env.DB.batch([
- env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 100, ?)").bind(id, now - 2000),
- env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 0, 500, 300, ?)").bind(id, now - 1000),
- env.DB.prepare("INSERT INTO incidents (monitor_id, started_at, start_status_code, start_error, created_at, updated_at) VALUES (?, ?, 500, 'Down', ?, ?)").bind(id, now - 1000, now - 1000, now - 1000),
+ env.DB.prepare('INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 100, ?)').bind(
+ id,
+ now - 2000,
+ ),
+ env.DB.prepare('INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 0, 500, 300, ?)').bind(
+ id,
+ now - 1000,
+ ),
+ env.DB.prepare(
+ "INSERT INTO incidents (monitor_id, started_at, start_status_code, start_error, created_at, updated_at) VALUES (?, ?, 500, 'Down', ?, ?)",
+ ).bind(id, now - 1000, now - 1000, now - 1000),
]);
const [detail, checksResponse, incidentsResponse, statsResponse] = await Promise.all([
- apiFetch(`/api/monitors/${id}`, "GET", cookie),
- apiFetch(`/api/monitors/${id}/checks`, "GET", cookie),
- apiFetch(`/api/monitors/${id}/incidents`, "GET", cookie),
- apiFetch(`/api/monitors/${id}/stats`, "GET", cookie),
+ apiFetch(`/api/monitors/${id}`, 'GET', cookie),
+ apiFetch(`/api/monitors/${id}/checks`, 'GET', cookie),
+ apiFetch(`/api/monitors/${id}/incidents`, 'GET', cookie),
+ apiFetch(`/api/monitors/${id}/stats`, 'GET', cookie),
]);
expect((await detail.json<{ monitor: { id: number } }>()).monitor.id).toBe(id);
expect((await checksResponse.json<{ checks: unknown[] }>()).checks).toHaveLength(2);
expect((await incidentsResponse.json<{ incidents: unknown[] }>()).incidents).toHaveLength(1);
- const stats = await statsResponse.json<{ windows: { "24h": { uptimePct: number; totalChecks: number; upChecks: number; avgLatencyMs: number; incidentCount: number } } }>();
- expect(stats.windows["24h"]).toEqual({ uptimePct: 50, totalChecks: 2, upChecks: 1, avgLatencyMs: 200, incidentCount: 1 });
+ const stats = await statsResponse.json<{
+ windows: { '24h': { uptimePct: number; totalChecks: number; upChecks: number; avgLatencyMs: number; incidentCount: number } };
+ }>();
+ expect(stats.windows['24h']).toEqual({ uptimePct: 50, totalChecks: 2, upChecks: 1, avgLatencyMs: 200, incidentCount: 1 });
});
- it("combines daily rollups with the current partial day for long-range stats", async () => {
+ it('combines daily rollups with the current partial day for long-range stats', async () => {
const cookie = await authenticatedCookie();
const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>();
const id = created.monitor.id;
const now = new Date();
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
await env.DB.batch([
- env.DB.prepare("INSERT INTO monitor_daily_stats (monitor_id, day, total_checks, up_checks, avg_latency_ms, min_latency_ms, max_latency_ms) VALUES (?, ?, 8, 6, 100, 50, 150)").bind(id, today - 24 * 60 * 60 * 1000),
- env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 200, ?)").bind(id, today + 1000),
- env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 200, ?)").bind(id, today + 2000),
+ env.DB.prepare(
+ 'INSERT INTO monitor_daily_stats (monitor_id, day, total_checks, up_checks, avg_latency_ms, min_latency_ms, max_latency_ms) VALUES (?, ?, 8, 6, 100, 50, 150)',
+ ).bind(id, today - 24 * 60 * 60 * 1000),
+ env.DB.prepare('INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 200, ?)').bind(
+ id,
+ today + 1000,
+ ),
+ env.DB.prepare('INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 200, ?)').bind(
+ id,
+ today + 2000,
+ ),
]);
- const response = await apiFetch(`/api/monitors/${id}/stats`, "GET", cookie);
- const stats = await response.json<{ windows: { "30d": { uptimePct: number; totalChecks: number; upChecks: number; avgLatencyMs: number } } }>();
- expect(stats.windows["30d"]).toMatchObject({
+ const response = await apiFetch(`/api/monitors/${id}/stats`, 'GET', cookie);
+ const stats = await response.json<{
+ windows: { '30d': { uptimePct: number; totalChecks: number; upChecks: number; avgLatencyMs: number } };
+ }>();
+ expect(stats.windows['30d']).toMatchObject({
uptimePct: 80,
totalChecks: 10,
upChecks: 8,
@@ -221,19 +245,19 @@ describe("monitor API", () => {
});
});
- it("deletes the monitor and its check records explicitly", async () => {
+ it('deletes the monitor and its check records explicitly', async () => {
const cookie = await authenticatedCookie();
const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>();
- await env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 12, ?)")
+ await env.DB.prepare('INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 12, ?)')
.bind(created.monitor.id, Date.now())
.run();
- const response = await apiFetch(`/api/monitors/${created.monitor.id}`, "DELETE", cookie);
+ const response = await apiFetch(`/api/monitors/${created.monitor.id}`, 'DELETE', cookie);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
- const monitorCount = await env.DB.prepare("SELECT COUNT(*) AS count FROM monitors").first<{ count: number }>();
- const checkCount = await env.DB.prepare("SELECT COUNT(*) AS count FROM checks").first<{ count: number }>();
+ const monitorCount = await env.DB.prepare('SELECT COUNT(*) AS count FROM monitors').first<{ count: number }>();
+ const checkCount = await env.DB.prepare('SELECT COUNT(*) AS count FROM checks').first<{ count: number }>();
expect(monitorCount?.count).toBe(0);
expect(checkCount?.count).toBe(0);
});
diff --git a/test/rollup.spec.ts b/test/rollup.spec.ts
index ce478e9..e73b223 100644
--- a/test/rollup.spec.ts
+++ b/test/rollup.spec.ts
@@ -1,40 +1,55 @@
-import { applyD1Migrations, env, type D1Migration } from "cloudflare:test";
-import { beforeAll, beforeEach, describe, expect, it } from "vitest";
-import { runDailyRollup } from "../src/worker/scheduled/rollup";
+import { applyD1Migrations, env, type D1Migration } from 'cloudflare:test';
+import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
+import { runDailyRollup } from '../src/worker/scheduled/rollup';
-describe("daily monitor rollups", () => {
+describe('daily monitor rollups', () => {
beforeAll(async () => {
const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
});
beforeEach(async () => {
await env.DB.batch([
- env.DB.prepare("DELETE FROM monitor_daily_stats"),
- env.DB.prepare("DELETE FROM checks"),
- env.DB.prepare("DELETE FROM incidents"),
- env.DB.prepare("DELETE FROM monitors"),
+ env.DB.prepare('DELETE FROM monitor_daily_stats'),
+ env.DB.prepare('DELETE FROM checks'),
+ env.DB.prepare('DELETE FROM incidents'),
+ env.DB.prepare('DELETE FROM monitors'),
]);
});
- it("aggregates the previous UTC day and safely upserts on rerun", async () => {
- const now = new Date("2026-08-28T00:05:00.000Z");
+ it('aggregates the previous UTC day and safely upserts on rerun', async () => {
+ const now = new Date('2026-08-28T00:05:00.000Z');
const createdAt = now.getTime();
- const insert = await env.DB.prepare("INSERT INTO monitors (name, url, method, expected_status, interval_seconds, timeout_ms, enabled, alerts_enabled, created_at, updated_at) VALUES ('API', 'https://example.com', 'GET', 200, 300, 10000, 1, 1, ?, ?)")
+ const insert = await env.DB.prepare(
+ "INSERT INTO monitors (name, url, method, expected_status, interval_seconds, timeout_ms, enabled, alerts_enabled, created_at, updated_at) VALUES ('API', 'https://example.com', 'GET', 200, 300, 10000, 1, 1, ?, ?)",
+ )
.bind(createdAt, createdAt)
.run();
const id = Number(insert.meta.last_row_id);
await env.DB.batch([
- env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 100, ?)").bind(id, Date.parse("2026-08-27T02:00:00Z")),
- env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 200, ?)").bind(id, Date.parse("2026-08-27T12:00:00Z")),
- env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 0, 500, 300, ?)").bind(id, Date.parse("2026-08-27T22:00:00Z")),
+ env.DB.prepare('INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 100, ?)').bind(
+ id,
+ Date.parse('2026-08-27T02:00:00Z'),
+ ),
+ env.DB.prepare('INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 200, ?)').bind(
+ id,
+ Date.parse('2026-08-27T12:00:00Z'),
+ ),
+ env.DB.prepare('INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 0, 500, 300, ?)').bind(
+ id,
+ Date.parse('2026-08-27T22:00:00Z'),
+ ),
]);
- expect(await runDailyRollup(env, now)).toEqual({ day: "2026-08-27", monitors: 1 });
+ expect(await runDailyRollup(env, now)).toEqual({ day: '2026-08-27', monitors: 1 });
await runDailyRollup(env, now);
- const rows = await env.DB.prepare("SELECT day, total_checks, up_checks, avg_latency_ms, min_latency_ms, max_latency_ms FROM monitor_daily_stats WHERE monitor_id = ?").bind(id).all();
+ const rows = await env.DB.prepare(
+ 'SELECT day, total_checks, up_checks, avg_latency_ms, min_latency_ms, max_latency_ms FROM monitor_daily_stats WHERE monitor_id = ?',
+ )
+ .bind(id)
+ .all();
expect(rows.results).toHaveLength(1);
expect(rows.results[0]).toEqual({
- day: Date.parse("2026-08-27T00:00:00Z"),
+ day: Date.parse('2026-08-27T00:00:00Z'),
total_checks: 3,
up_checks: 2,
avg_latency_ms: 200,
diff --git a/test/status.spec.ts b/test/status.spec.ts
index 9c6f6a3..48e96b6 100644
--- a/test/status.spec.ts
+++ b/test/status.spec.ts
@@ -1,15 +1,15 @@
-import { applyD1Migrations, env, SELF, type D1Migration } from "cloudflare:test";
-import { beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { applyD1Migrations, env, SELF, type D1Migration } from 'cloudflare:test';
+import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
const DAY_MS = 24 * 60 * 60 * 1000;
type PublicStatusResponse = {
- overall: "operational" | "degraded" | "down";
+ overall: 'operational' | 'degraded' | 'down';
updatedAt: number;
services: Array<{
id: number;
name: string;
- status: "up" | "down" | "unknown";
+ status: 'up' | 'down' | 'unknown';
lastCheckedAt: string | null;
uptime90d: number | null;
history: Array<{ day: number; uptimePct: number | null }>;
@@ -18,14 +18,14 @@ type PublicStatusResponse = {
async function resetDatabase() {
await env.DB.batch([
- env.DB.prepare("DELETE FROM checks"),
- env.DB.prepare("DELETE FROM incidents"),
- env.DB.prepare("DELETE FROM monitor_daily_stats"),
- env.DB.prepare("DELETE FROM notification_settings"),
- env.DB.prepare("DELETE FROM monitors"),
- env.DB.prepare("DELETE FROM login_attempts"),
- env.DB.prepare("DELETE FROM sessions"),
- env.DB.prepare("DELETE FROM admin_credentials"),
+ env.DB.prepare('DELETE FROM checks'),
+ env.DB.prepare('DELETE FROM incidents'),
+ env.DB.prepare('DELETE FROM monitor_daily_stats'),
+ env.DB.prepare('DELETE FROM notification_settings'),
+ env.DB.prepare('DELETE FROM monitors'),
+ env.DB.prepare('DELETE FROM login_attempts'),
+ env.DB.prepare('DELETE FROM sessions'),
+ env.DB.prepare('DELETE FROM admin_credentials'),
]);
}
@@ -39,13 +39,15 @@ async function insertMonitor(input: {
lastError?: string | null;
}) {
const now = Date.now();
- const result = await env.DB.prepare(`
+ const result = await env.DB.prepare(
+ `
INSERT INTO monitors (
name, url, method, expected_status, interval_seconds, timeout_ms,
enabled, alerts_enabled, last_ok, last_status_code, last_latency_ms,
last_error, last_checked_at, created_at, updated_at
) VALUES (?, ?, 'GET', 200, 300, 10000, ?, 1, ?, ?, ?, ?, ?, ?, ?)
- `)
+ `,
+ )
.bind(
input.name,
input.url ?? `https://${input.name.toLowerCase()}.example.com/health`,
@@ -62,76 +64,84 @@ async function insertMonitor(input: {
return Number(result.meta.last_row_id);
}
-function statusFetch(path = "/api/status") {
+function statusFetch(path = '/api/status') {
return SELF.fetch(`https://example.com${path}`);
}
-describe("public status API", () => {
+describe('public status API', () => {
beforeAll(async () => {
const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
});
beforeEach(resetDatabase);
- it("returns public status without authentication and omits sensitive monitor fields", async () => {
+ it('returns public status without authentication and omits sensitive monitor fields', async () => {
await insertMonitor({
- name: "Public API",
+ name: 'Public API',
lastOk: true,
lastStatusCode: 200,
lastLatencyMs: 42,
- lastError: "sensitive diagnostic",
+ lastError: 'sensitive diagnostic',
});
const response = await statusFetch();
const body = await response.json();
expect(response.status).toBe(200);
- expect(body.overall).toBe("operational");
+ expect(body.overall).toBe('operational');
expect(body.updatedAt).toEqual(expect.any(Number));
expect(body.services).toHaveLength(1);
expect(body.services[0]).toMatchObject({
- name: "Public API",
- status: "up",
+ name: 'Public API',
+ status: 'up',
uptime90d: null,
history: [],
});
- for (const privateField of ["url", "lastError", "lastStatusCode", "lastLatencyMs", "method", "timeoutMs"]) {
+ for (const privateField of ['url', 'lastError', 'lastStatusCode', 'lastLatencyMs', 'method', 'timeoutMs']) {
expect(body.services[0]).not.toHaveProperty(privateField);
}
});
- it("excludes disabled monitors and reports degraded health for a partial outage", async () => {
- await insertMonitor({ name: "Healthy", lastOk: true });
- await insertMonitor({ name: "Unavailable", lastOk: false });
- await insertMonitor({ name: "Disabled secret", enabled: false, lastOk: false });
- await insertMonitor({ name: "New service", lastOk: null });
+ it('excludes disabled monitors and reports degraded health for a partial outage', async () => {
+ await insertMonitor({ name: 'Healthy', lastOk: true });
+ await insertMonitor({ name: 'Unavailable', lastOk: false });
+ await insertMonitor({ name: 'Disabled secret', enabled: false, lastOk: false });
+ await insertMonitor({ name: 'New service', lastOk: null });
const response = await statusFetch();
const body = await response.json();
expect(response.status).toBe(200);
- expect(body.overall).toBe("degraded");
- expect(body.services.map((service) => service.name)).toEqual(["Healthy", "Unavailable", "New service"]);
- expect(body.services.map((service) => service.status)).toEqual(["up", "down", "unknown"]);
+ expect(body.overall).toBe('degraded');
+ expect(body.services.map((service) => service.name)).toEqual(['Healthy', 'Unavailable', 'New service']);
+ expect(body.services.map((service) => service.status)).toEqual(['up', 'down', 'unknown']);
});
- it("reports a total outage when every checked service is down", async () => {
- await insertMonitor({ name: "Website", lastOk: false });
- await insertMonitor({ name: "API", lastOk: false });
- await insertMonitor({ name: "Not checked", lastOk: null });
+ it('reports a total outage when every checked service is down', async () => {
+ await insertMonitor({ name: 'Website', lastOk: false });
+ await insertMonitor({ name: 'API', lastOk: false });
+ await insertMonitor({ name: 'Not checked', lastOk: null });
const body = await (await statusFetch()).json();
- expect(body.overall).toBe("down");
+ expect(body.overall).toBe('down');
});
it("combines historical daily rollups and today's checks into 90-day uptime", async () => {
- const id = await insertMonitor({ name: "Aggregated", lastOk: true });
+ const id = await insertMonitor({ name: 'Aggregated', lastOk: true });
const now = new Date();
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
await env.DB.batch([
- env.DB.prepare("INSERT INTO monitor_daily_stats (monitor_id, day, total_checks, up_checks, avg_latency_ms, min_latency_ms, max_latency_ms) VALUES (?, ?, 8, 6, 100, 50, 150)").bind(id, today - DAY_MS),
- env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 90, ?)").bind(id, today + 1_000),
- env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 110, ?)").bind(id, today + 2_000),
+ env.DB.prepare(
+ 'INSERT INTO monitor_daily_stats (monitor_id, day, total_checks, up_checks, avg_latency_ms, min_latency_ms, max_latency_ms) VALUES (?, ?, 8, 6, 100, 50, 150)',
+ ).bind(id, today - DAY_MS),
+ env.DB.prepare('INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 90, ?)').bind(
+ id,
+ today + 1_000,
+ ),
+ env.DB.prepare('INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 110, ?)').bind(
+ id,
+ today + 2_000,
+ ),
]);
const body = await (await statusFetch()).json();
@@ -144,16 +154,16 @@ describe("public status API", () => {
]);
});
- it("keeps the administrative monitor collection protected", async () => {
- const response = await SELF.fetch("https://example.com/api/monitors");
+ it('keeps the administrative monitor collection protected', async () => {
+ const response = await SELF.fetch('https://example.com/api/monitors');
expect(response.status).toBe(401);
- expect(await response.json()).toEqual({ message: "Authentication required" });
+ expect(await response.json()).toEqual({ message: 'Authentication required' });
});
- it("does not expose a favicon for a disabled service", async () => {
- const id = await insertMonitor({ name: "Private", enabled: false });
+ it('does not expose a favicon for a disabled service', async () => {
+ const id = await insertMonitor({ name: 'Private', enabled: false });
const response = await statusFetch(`/api/status/${id}/favicon`);
expect(response.status).toBe(404);
- expect(await response.json()).toEqual({ message: "Service not found" });
+ expect(await response.json()).toEqual({ message: 'Service not found' });
});
});
diff --git a/test/webhook.spec.ts b/test/webhook.spec.ts
index 9c0c3c0..27c0134 100644
--- a/test/webhook.spec.ts
+++ b/test/webhook.spec.ts
@@ -1,44 +1,74 @@
-import { applyD1Migrations, env, type D1Migration } from "cloudflare:test";
-import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
-import { sendIncidentAlert } from "../src/worker/notifications/webhook";
-import type { Monitor } from "../src/worker/checks/run-check";
+import { applyD1Migrations, env, type D1Migration } from 'cloudflare:test';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { sendIncidentAlert } from '../src/worker/notifications/webhook';
+import type { Monitor } from '../src/worker/checks/run-check';
-describe("incident webhooks", () => {
+describe('incident webhooks', () => {
beforeAll(async () => {
const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
});
beforeEach(async () => {
- await env.DB.prepare("DELETE FROM notification_settings").run();
+ await env.DB.prepare('DELETE FROM notification_settings').run();
const now = Date.now();
- await env.DB.prepare("INSERT INTO notification_settings (id, webhook_url, webhook_enabled, created_at, updated_at) VALUES (1, 'https://hooks.example.test/events', 1, ?, ?)").bind(now, now).run();
+ await env.DB.prepare(
+ "INSERT INTO notification_settings (id, webhook_url, webhook_enabled, created_at, updated_at) VALUES (1, 'https://hooks.example.test/events', 1, ?, ?)",
+ )
+ .bind(now, now)
+ .run();
});
afterEach(() => vi.unstubAllGlobals());
const monitor = {
- id: 7, name: "API", url: "https://api.example.com", method: "GET", expectedStatus: 200,
- intervalSeconds: 300, timeoutMs: 10000, enabled: true, alertsEnabled: true, lastOk: true,
- lastStatusCode: 200, lastLatencyMs: 30, lastError: null, lastCheckedAt: null,
- createdAt: new Date(), updatedAt: new Date(),
+ id: 7,
+ name: 'API',
+ url: 'https://api.example.com',
+ method: 'GET',
+ expectedStatus: 200,
+ intervalSeconds: 300,
+ timeoutMs: 10000,
+ enabled: true,
+ alertsEnabled: true,
+ lastOk: true,
+ lastStatusCode: 200,
+ lastLatencyMs: 30,
+ lastError: null,
+ lastCheckedAt: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
} satisfies Monitor;
- it("sends the compact down payload", async () => {
+ it('sends the compact down payload', async () => {
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
- vi.stubGlobal("fetch", fetchMock);
- const at = new Date("2026-08-28T03:25:00Z");
- expect(await sendIncidentAlert(env, { monitor, kind: "opened", result: { ok: false, statusCode: 500, latencyMs: 42, error: "Down" }, at })).toBe(true);
+ vi.stubGlobal('fetch', fetchMock);
+ const at = new Date('2026-08-28T03:25:00Z');
+ expect(
+ await sendIncidentAlert(env, { monitor, kind: 'opened', result: { ok: false, statusCode: 500, latencyMs: 42, error: 'Down' }, at }),
+ ).toBe(true);
const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(String(init?.body))).toEqual({
- event: "down",
- monitor: { id: 7, name: "API", url: "https://api.example.com" },
+ event: 'down',
+ monitor: { id: 7, name: 'API', url: 'https://api.example.com' },
statusCode: 500,
- error: "Down",
- at: "2026-08-28T03:25:00.000Z",
+ error: 'Down',
+ at: '2026-08-28T03:25:00.000Z',
});
});
- it("swallows webhook network failures", async () => {
- vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network unavailable"); }));
- await expect(sendIncidentAlert(env, { monitor, kind: "resolved", result: { ok: true, statusCode: 200, latencyMs: 20, error: null }, at: new Date() })).resolves.toBe(false);
+ it('swallows webhook network failures', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => {
+ throw new Error('network unavailable');
+ }),
+ );
+ await expect(
+ sendIncidentAlert(env, {
+ monitor,
+ kind: 'resolved',
+ result: { ok: true, statusCode: 200, latencyMs: 20, error: null },
+ at: new Date(),
+ }),
+ ).resolves.toBe(false);
});
});
diff --git a/tsconfig.json b/tsconfig.json
index e3a9f5a..a038497 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -15,10 +15,5 @@
"types": ["./worker-configuration.d.ts", "vite/client", "node"]
},
"exclude": ["dist", "node_modules", "test"],
- "include": [
- "worker-configuration.d.ts",
- "src/**/*.ts",
- "src/**/*.tsx",
- "vite.config.ts"
- ]
+ "include": ["worker-configuration.d.ts", "src/**/*.ts", "src/**/*.tsx", "vite.config.ts"]
}
diff --git a/vite.config.ts b/vite.config.ts
index aaed973..9395bdd 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,7 +1,7 @@
-import { cloudflare } from "@cloudflare/vite-plugin";
-import tailwindcss from "@tailwindcss/vite";
-import react from "@vitejs/plugin-react";
-import { defineConfig } from "vite";
+import { cloudflare } from '@cloudflare/vite-plugin';
+import tailwindcss from '@tailwindcss/vite';
+import react from '@vitejs/plugin-react';
+import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react(), tailwindcss(), cloudflare()],
diff --git a/vitest.config.mts b/vitest.config.mts
index 78c41b4..b10fb1e 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -1,12 +1,12 @@
-import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-plugin";
-import { defineConfig } from "vitest/config";
+import { cloudflareTest, readD1Migrations } from '@cloudflare/vitest-plugin';
+import { defineConfig } from 'vitest/config';
-const migrations = await readD1Migrations("./migrations");
+const migrations = await readD1Migrations('./migrations');
export default defineConfig({
plugins: [
cloudflareTest({
- wrangler: { configPath: "./wrangler.jsonc" },
+ wrangler: { configPath: './wrangler.jsonc' },
miniflare: {
bindings: { TEST_MIGRATIONS: migrations },
},
diff --git a/wrangler.jsonc b/wrangler.jsonc
index 51cbbef..ce9715e 100644
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -7,24 +7,24 @@
"assets": {
"directory": "./dist/client",
"not_found_handling": "single-page-application",
- "run_worker_first": ["/api/*"]
+ "run_worker_first": ["/api/*"],
},
"triggers": {
- "crons": ["*/5 * * * *", "5 0 * * *"]
+ "crons": ["*/5 * * * *", "5 0 * * *"],
},
"d1_databases": [
{
"binding": "DB",
"database_name": "uptime",
"database_id": "960b26fd-a11b-4899-826b-d3513e499d3a",
- "migrations_dir": "migrations"
- }
+ "migrations_dir": "migrations",
+ },
],
"observability": {
"enabled": true,
"logs": {
- "head_sampling_rate": 1
- }
+ "head_sampling_rate": 1,
+ },
},
- "upload_source_maps": true
+ "upload_source_maps": true,
}