feat: setup oxlint and prettier for the project

This commit is contained in:
2026-08-28 20:03:19 +07:00
parent 9766edad03
commit 3dcc812cd4
65 changed files with 3940 additions and 1741 deletions
+16
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
bunx lint-staged
+42
View File
@@ -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/**"]
}
+6
View File
@@ -0,0 +1,6 @@
dist
.wrangler
node_modules
bun.lock
worker-configuration.d.ts
migrations
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["esbenp.prettier-vscode", "oxc.oxc-vscode", "EditorConfig.EditorConfig"]
}
+20
View File
@@ -1,5 +1,25 @@
{ {
"files.associations": { "files.associations": {
"wrangler.json": "jsonc" "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"
} }
} }
+65 -34
View File
@@ -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. 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:** **Key Characteristics:**
- Single emerald primary (`{colors.primary}` `#3ecf8e`) as the only chromatic event; everything else is monochrome. - 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}`. - 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. - 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`. > **Source pages:** home (`/`), `/database`, `/partners/integrations`, `/partners/integrations/powersync`, `/solutions/ai-builders`, `/pricing`.
### Brand & Accent ### Brand & Accent
- **Emerald** (`{colors.primary}``#3ecf8e`): The signature CTA color. Filled-button background, brand wordmark accent, dot indicator. - **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 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. - **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. - **Accent Pink / Crimson / Indigo / Tomato**: Reserved for integration logos and rare chart highlights, never as system colors.
### Surface ### Surface
- **Canvas** (`{colors.canvas}``#ffffff`): Default page background. - **Canvas** (`{colors.canvas}``#ffffff`): Default page background.
- **Canvas Soft** (`{colors.canvas-soft}``#fafafa`): Barely-tinted off-white for alternating section bands. - **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. - **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. - **Hairline Cool** (`{colors.hairline-cool}``#ededed`) / **Hairline Cool 2** (`#efefef`) / **Hairline Cool 3** (`#d4d4d4`): The brand's grey ladder for fine chrome work.
### Text ### Text
- **Ink** (`{colors.ink}``#171717`): Default body text. Near-black, never pure. - **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 Secondary** (`{colors.ink-secondary}``#212121`): Slightly cooler near-black for body emphasis.
- **Ink Mute** (`{colors.ink-mute}``#707070`): Secondary text and helper copy. - **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 ### Hierarchy
| Token | Size | Weight | Line Height | Letter Spacing | Use | | Token | Size | Weight | Line Height | Letter Spacing | Use |
|---|---|---|---|---|---| | -------------------------- | ---- | ------ | ----------- | -------------- | -------------------------- |
| `{typography.display-xxl}` | 64px | 500 | 1.1 | -1.92px | Hero headline | | `{typography.display-xxl}` | 64px | 500 | 1.1 | -1.92px | Hero headline |
| `{typography.display-xl}` | 48px | 500 | 1.1 | -1.44px | Section opener | | `{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-lg}` | 36px | 500 | 1.15 | -0.72px | Sub-section / pricing tier |
| `{typography.display-md}` | 28px | 500 | 1.2 | -0.42px | Card title | | `{typography.display-md}` | 28px | 500 | 1.2 | -0.42px | Card title |
| `{typography.heading-lg}` | 22px | 500 | 1.2 | 0 | Compact heading | | `{typography.heading-lg}` | 22px | 500 | 1.2 | 0 | Compact heading |
| `{typography.heading-md}` | 18px | 500 | 1.4 | 0 | Section sub-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-lg}` | 18px | 400 | 1.55 | 0 | Marketing body lead |
| `{typography.body-md}` | 16px | 400 | 1.5 | 0 | Default UI body | | `{typography.body-md}` | 16px | 400 | 1.5 | 0 | Default UI body |
| `{typography.button-md}` | 14px | 500 | 1.0 | 0 | Button label | | `{typography.button-md}` | 14px | 500 | 1.0 | 0 | Button label |
| `{typography.caption}` | 13px | 400 | 1.45 | 0 | Helper, footnote | | `{typography.caption}` | 13px | 400 | 1.45 | 0 | Helper, footnote |
| `{typography.micro}` | 12px | 400 | 1.45 | 0 | Pill label, fine print | | `{typography.micro}` | 12px | 400 | 1.45 | 0 | Pill label, fine print |
| `{typography.code}` | 14px | 400 | 1.5 | 0 | Code block content | | `{typography.code}` | 14px | 400 | 1.5 | 0 | Code block content |
### Principles ### Principles
- **Weight 500 across display.** Mid-weight reads as engineered, not decorative. - **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. - **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. - **Mono for code.** System mono families (Menlo / Monaco) — no proprietary mono webfont.
### Note on Font Substitutes ### 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. 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 ## Layout
### Spacing System ### Spacing System
- **Base unit**: 8px (with 2 / 4 / 12 sub-tokens for fine work). - **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. - **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**: 6496px on marketing surfaces. - **Section padding**: 6496px on marketing surfaces.
- **Card internal padding**: 32px on feature/pricing cards. - **Card internal padding**: 32px on feature/pricing cards.
### Grid & Container ### Grid & Container
- Marketing pages center in a ~1280px container with no edge-bleed; the brand keeps content inside the box. - 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. - 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. - Product UI mockups stack 2-up or render as overlapping panes inside the same container.
### Whitespace Philosophy ### Whitespace Philosophy
The brand uses generous 6496px 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. The brand uses generous 6496px 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 ## Elevation & Depth
| Level | Treatment | Use | | Level | Treatment | Use |
|---|---|---| | ----- | ------------------------------------------ | ------------------------------ |
| 0 | Flat, 1px hairline | Default cards | | 0 | Flat, 1px hairline | Default cards |
| 1 | `box-shadow: 0 1px 3px rgba(0,0,0,0.06)` | Subtle card lift | | 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 | | 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 | | 3 | `box-shadow: 0 16px 48px rgba(0,0,0,0.12)` | Modal overlays, deep elevation |
### Decorative Depth ### 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. 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 ## Shapes
### Border Radius Scale ### Border Radius Scale
| Token | Value | Use | | Token | Value | Use |
|---|---|---| | ---------------- | ------ | ---------------------------------------------------------- |
| `{rounded.xs}` | 4px | Form inputs, hairline tags | | `{rounded.xs}` | 4px | Form inputs, hairline tags |
| `{rounded.sm}` | 6px | Buttons (the brand's signature button radius), code blocks | | `{rounded.sm}` | 6px | Buttons (the brand's signature button radius), code blocks |
| `{rounded.md}` | 8px | Compact cards, alerts | | `{rounded.md}` | 8px | Compact cards, alerts |
| `{rounded.lg}` | 12px | Pricing cards, feature cards, product mockups | | `{rounded.lg}` | 12px | Pricing cards, feature cards, product mockups |
| `{rounded.xl}` | 16px | Modal dialogs, large container chrome | | `{rounded.xl}` | 16px | Modal dialogs, large container chrome |
| `{rounded.full}` | 9999px | Pill tags, avatars | | `{rounded.full}` | 9999px | Pill tags, avatars |
### Photography Geometry ### Photography Geometry
The brand uses minimal photography. Customer logo strips display wordmarks at uniform height (~2432px) in greyscale; case-study cards (rare) use 4:3 photos inset in `{rounded.lg}` containers. The brand uses minimal photography. Customer logo strips display wordmarks at uniform height (~2432px) in greyscale; case-study cards (rare) use 4:3 photos inset in `{rounded.lg}` containers.
## Components ## Components
@@ -355,51 +366,64 @@ The brand uses minimal photography. Customer logo strips display wordmarks at un
### Buttons ### Buttons
**`button-primary-green`** — the signature CTA. **`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. - 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}`. - Pressed state `button-primary-green-pressed` shifts to `{colors.primary-deep}`.
**`button-secondary-outline`** — outline alternative on white. **`button-secondary-outline`** — outline alternative on white.
- Background `{colors.canvas}`, text `{colors.ink}`, 1px solid `{colors.hairline-strong}` border, same shape. - Background `{colors.canvas}`, text `{colors.ink}`, 1px solid `{colors.hairline-strong}` border, same shape.
**`button-on-dark`** — used on dark surfaces / code-block CTAs. **`button-on-dark`** — used on dark surfaces / code-block CTAs.
- Background `{colors.canvas-night}`, text `{colors.on-dark}`, same shape. - Background `{colors.canvas-night}`, text `{colors.on-dark}`, same shape.
**`button-link`** — text-only inline button. **`button-link`** — text-only inline button.
- Transparent background, text `{colors.ink}` rendered in `{typography.button-md}`, no padding, with a subtle underline on hover. - Transparent background, text `{colors.ink}` rendered in `{typography.button-md}`, no padding, with a subtle underline on hover.
### Cards & Containers ### Cards & Containers
**`card-feature-light`** — feature card on white. **`card-feature-light`** — feature card on white.
- Background `{colors.canvas}`, padding `{spacing.xxl}`, rounded `{rounded.lg}` 12px, 1px `{colors.hairline}` border. - Background `{colors.canvas}`, padding `{spacing.xxl}`, rounded `{rounded.lg}` 12px, 1px `{colors.hairline}` border.
**`card-pricing`** — standard pricing tier. **`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. - 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. **`card-pricing-featured`** — inverted dark featured tier.
- Background `{colors.canvas-night}`, text `{colors.on-dark}`, otherwise identical structure. - Background `{colors.canvas-night}`, text `{colors.on-dark}`, otherwise identical structure.
**`card-feature-dark`** — feature card with deep dark fill. **`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. - 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. **`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. - Background `{colors.canvas-night}`, text `{colors.on-dark}` rendered in `{typography.code}`. Padding `{spacing.lg}` 16px, rounded `{rounded.sm}` 6px.
### Inputs & Forms ### Inputs & Forms
**`text-input`** — standard form input. **`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. - 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 ### Navigation
**`nav-bar-light`** — top nav across the site. **`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. - 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 ### Pills, Tags, and Chips
**`pill-tag-green`** — small green pill used for "new" or featured indicators. **`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}`. - 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. **`pill-tag-soft`** — neutral pill on light surfaces.
- Background `{colors.canvas-soft}`, text `{colors.ink}`, otherwise same shape. - Background `{colors.canvas-soft}`, text `{colors.ink}`, otherwise same shape.
### Signature Components ### 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. **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. **`link-on-light`** — inline links in body copy.
- Text `{colors.ink}` rendered in `{typography.body-md}` with a persistent underline. - Text `{colors.ink}` rendered in `{typography.body-md}` with a persistent underline.
**`footer-light`** — site-wide footer. **`footer-light`** — site-wide footer.
- Background `{colors.canvas}`, text `{colors.ink-mute}`, type `{typography.caption}`, padding `{spacing.huge} {spacing.xl}` (64px 24px). Holds 45 columns of link groups, social icons, and a small legal row. - Background `{colors.canvas}`, text `{colors.ink-mute}`, type `{typography.caption}`, padding `{spacing.huge} {spacing.xl}` (64px 24px). Holds 45 columns of link groups, social icons, and a small legal row.
## Do's and Don'ts ## Do's and Don'ts
### Do ### Do
- Reserve `{colors.primary}` emerald for filled CTAs and the wordmark accent — it should appear sparingly. - 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. - 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. - 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. - Apply system mono for every code block.
### Don't ### 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 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 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. - 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 ### Breakpoints
| Name | Width | Key Changes | | Name | Width | Key Changes |
|---|---|---| | ------- | ----------- | ---------------------------------------------------- |
| Wide | ≥ 1440px | Full container width; product mockups at full scale | | Wide | ≥ 1440px | Full container width; product mockups at full scale |
| Desktop | 10241440px | Default content max-width; pricing 4-up | | Desktop | 10241440px | Default content max-width; pricing 4-up |
| Tablet | 7681023px | Pricing 2-up; mockups simplify to single panel | | Tablet | 7681023px | Pricing 2-up; mockups simplify to single panel |
| Mobile | < 768px | Pricing 1-up; hamburger nav; display drops 64 → 36px | | Mobile | < 768px | Pricing 1-up; hamburger nav; display drops 64 → 36px |
### Touch Targets ### Touch Targets
- Buttons hit ≥ 36×36px on mobile; vertical padding scales up to maintain WCAG AA minimum. - Buttons hit ≥ 36×36px on mobile; vertical padding scales up to maintain WCAG AA minimum.
- Form fields stay at 36px minimum height. - Form fields stay at 36px minimum height.
### Collapsing Strategy ### Collapsing Strategy
- Display tiers stair-step 64 → 48 → 36 → 28 → 22px. - Display tiers stair-step 64 → 48 → 36 → 28 → 22px.
- Product UI mockups simplify to a single primary panel on mobile. - 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. - Pricing tiers stair-step 4-up → 2-up → 1-up; dark featured tier always distinguished.
### Image Behavior ### Image Behavior
Product UI mockups use `srcset` with desktop / mobile crops; mobile crops focus on the most actionable inner panel. Product UI mockups use `srcset` with desktop / mobile crops; mobile crops focus on the most actionable inner panel.
## Iteration Guide ## Iteration Guide
+54
View File
@@ -22,6 +22,10 @@
"@types/react-dom": "^19.2.5", "@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.0", "@vitejs/plugin-react": "^6.1.0",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"husky": "^9.1.7",
"lint-staged": "^17.4.1",
"oxlint": "^1.80.0",
"prettier": "^3.9.6",
"tailwindcss": "4", "tailwindcss": "4",
"typescript": "^5.5.2", "typescript": "^5.5.2",
"vite": "^8.2.2", "vite": "^8.2.2",
@@ -181,6 +185,44 @@
"@oxc-project/types": ["@oxc-project/types@0.147.0", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="], "@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/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=="], "@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=="], "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=="], "immer": ["immer@11.1.18", "", {}, "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], "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=="], "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=="], "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=="], "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=="], "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=="], "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "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=="], "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": ["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=="], "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=="], "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=="], "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], "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=="], "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": ["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=="], "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
+4 -4
View File
@@ -1,7 +1,7 @@
import { defineConfig } from "drizzle-kit"; import { defineConfig } from 'drizzle-kit';
export default defineConfig({ export default defineConfig({
dialect: "sqlite", dialect: 'sqlite',
schema: "./src/worker/db/schema.ts", schema: './src/worker/db/schema.ts',
out: "./migrations", out: './migrations',
}); });
+1 -4
View File
@@ -3,10 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta name="description" content="Upwatch — fast, dependable uptime monitoring from Cloudflare's edge." />
name="description"
content="Upwatch — fast, dependable uptime monitoring from Cloudflare's edge."
/>
<meta name="theme-color" content="#ffffff" /> <meta name="theme-color" content="#ffffff" />
<title>Upwatch — Uptime monitoring</title> <title>Upwatch — Uptime monitoring</title>
</head> </head>
+20 -2
View File
@@ -11,7 +11,12 @@
"types": "wrangler types", "types": "wrangler types",
"db:local": "wrangler d1 migrations apply uptime --local", "db:local": "wrangler d1 migrations apply uptime --local",
"db:remote": "wrangler d1 migrations apply uptime --remote", "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": { "devDependencies": {
"@cloudflare/vite-plugin": "^1.54.1", "@cloudflare/vite-plugin": "^1.54.1",
@@ -22,6 +27,10 @@
"@types/react-dom": "^19.2.5", "@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.0", "@vitejs/plugin-react": "^6.1.0",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"husky": "^9.1.7",
"lint-staged": "^17.4.1",
"oxlint": "^1.80.0",
"prettier": "^3.9.6",
"tailwindcss": "4", "tailwindcss": "4",
"typescript": "^5.5.2", "typescript": "^5.5.2",
"vite": "^8.2.2", "vite": "^8.2.2",
@@ -40,5 +49,14 @@
"trustedDependencies": [ "trustedDependencies": [
"workerd", "workerd",
"esbuild" "esbuild"
] ],
"lint-staged": {
"*.{ts,tsx}": [
"oxlint --fix",
"prettier --write"
],
"*.{js,jsx,json,jsonc,css,md}": [
"prettier --write"
]
}
} }
+17 -17
View File
@@ -1,35 +1,35 @@
import { hashPassword } from "../src/worker/lib/password"; import { hashPassword } from '../src/worker/lib/password';
async function readPassword() { async function readPassword() {
if (!process.stdin.isTTY) { if (!process.stdin.isTTY) {
return (await new Response(Bun.stdin.stream()).text()).trimEnd(); return (await new Response(Bun.stdin.stream()).text()).trimEnd();
} }
process.stdout.write("Password: "); process.stdout.write('Password: ');
process.stdin.setRawMode(true); process.stdin.setRawMode(true);
process.stdin.resume(); process.stdin.resume();
process.stdin.setEncoding("utf8"); process.stdin.setEncoding('utf8');
return new Promise<string>((resolve, reject) => { return new Promise<string>((resolve, reject) => {
let password = ""; let password = '';
const cleanup = () => { const cleanup = () => {
process.stdin.setRawMode(false); process.stdin.setRawMode(false);
process.stdin.pause(); process.stdin.pause();
process.stdout.write("\n"); process.stdout.write('\n');
}; };
process.stdin.on("data", (key: string) => { process.stdin.on('data', (key: string) => {
if (key === "\u0003") { if (key === '\u0003') {
cleanup(); cleanup();
reject(new Error("Cancelled")); reject(new Error('Cancelled'));
return; return;
} }
if (key === "\r" || key === "\n") { if (key === '\r' || key === '\n') {
cleanup(); cleanup();
resolve(password); resolve(password);
return; return;
} }
if (key === "\u007f" || key === "\b") { if (key === '\u007f' || key === '\b') {
password = password.slice(0, -1); password = password.slice(0, -1);
return; return;
} }
@@ -43,23 +43,23 @@ function sqlValue(value: string) {
} }
function shellDoubleQuoted(value: string) { function shellDoubleQuoted(value: string) {
return value.replace(/[\\"$`]/g, "\\$&"); return value.replace(/[\\"$`]/g, '\\$&');
} }
const password = await readPassword(); const password = await readPassword();
if (password.length < 8) { 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); process.exit(1);
} }
const now = Date.now(); const now = Date.now();
const statement = [ const statement = [
"INSERT OR REPLACE INTO admin_credentials", 'INSERT OR REPLACE INTO admin_credentials',
"(id, password_hash, created_at, updated_at)", '(id, password_hash, created_at, updated_at)',
`VALUES (1, ${sqlValue(await hashPassword(password))}, ${now}, ${now});`, `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(`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)}"`); console.log(`bunx wrangler d1 execute uptime --remote --command "${shellDoubleQuoted(statement)}"`);
+16 -18
View File
@@ -1,26 +1,24 @@
import { RequireAuth } from "./components/RequireAuth"; import { RequireAuth } from './components/RequireAuth';
import { usePathname } from "./lib/router"; import { usePathname } from './lib/router';
import { DashboardPage } from "./pages/DashboardPage"; import { DashboardPage } from './pages/DashboardPage';
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from './pages/LoginPage';
import { MonitorDetailPage } from "./pages/MonitorDetailPage"; import { MonitorDetailPage } from './pages/MonitorDetailPage';
import { SettingsPage } from "./pages/SettingsPage"; import { SettingsPage } from './pages/SettingsPage';
import { StatusPage } from "./pages/StatusPage"; import { StatusPage } from './pages/StatusPage';
function App() { function App() {
const pathname = usePathname(); const pathname = usePathname();
if (pathname === "/") return <StatusPage />; if (pathname === '/') return <StatusPage />;
if (pathname === "/login") return <LoginPage />; if (pathname === '/login') return <LoginPage />;
const monitorMatch = pathname.match(/^\/monitors\/(\d+)\/?$/); const monitorMatch = pathname.match(/^\/monitors\/(\d+)\/?$/);
const page = monitorMatch const page = monitorMatch ? (
? <MonitorDetailPage id={Number(monitorMatch[1])} /> <MonitorDetailPage id={Number(monitorMatch[1])} />
: pathname === "/settings" ) : pathname === '/settings' ? (
? <SettingsPage /> <SettingsPage />
: <DashboardPage />; ) : (
return ( <DashboardPage />
<RequireAuth>
{page}
</RequireAuth>
); );
return <RequireAuth>{page}</RequireAuth>;
} }
export default App; export default App;
+5 -5
View File
@@ -1,4 +1,4 @@
import { getJson, postJson } from "./http"; import { getJson, postJson } from './http';
export type SessionResponse = { export type SessionResponse = {
authenticated: boolean; authenticated: boolean;
@@ -9,16 +9,16 @@ export type LoginInput = {
}; };
export function getSession(signal?: AbortSignal) { export function getSession(signal?: AbortSignal) {
return getJson<SessionResponse>("/api/auth/me", { return getJson<SessionResponse>('/api/auth/me', {
signal, signal,
credentials: "same-origin", credentials: 'same-origin',
}); });
} }
export function login(input: LoginInput) { export function login(input: LoginInput) {
return postJson<SessionResponse>("/api/auth/login", input); return postJson<SessionResponse>('/api/auth/login', input);
} }
export function logout() { export function logout() {
return postJson<{ ok: true }>("/api/auth/logout"); return postJson<{ ok: true }>('/api/auth/logout');
} }
+2 -2
View File
@@ -1,4 +1,4 @@
import { getJson } from "./http"; import { getJson } from './http';
export type HealthResponse = { export type HealthResponse = {
ok: boolean; ok: boolean;
@@ -7,5 +7,5 @@ export type HealthResponse = {
}; };
export function getHealth(signal?: AbortSignal) { export function getHealth(signal?: AbortSignal) {
return getJson<HealthResponse>("/api/health", { signal }); return getJson<HealthResponse>('/api/health', { signal });
} }
+15 -38
View File
@@ -4,14 +4,14 @@ export class ApiError extends Error {
readonly status: number, readonly status: number,
) { ) {
super(message); super(message);
this.name = "ApiError"; this.name = 'ApiError';
} }
} }
async function getErrorMessage(response: Response) { async function getErrorMessage(response: Response) {
try { try {
const body = await response.json<{ message?: unknown }>(); 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; return body.message;
} }
} catch { } catch {
@@ -21,12 +21,9 @@ async function getErrorMessage(response: Response) {
return `Request returned HTTP ${response.status}`; return `Request returned HTTP ${response.status}`;
} }
export async function getJson<T>( export async function getJson<T>(input: RequestInfo | URL, init: RequestInit = {}): Promise<T> {
input: RequestInfo | URL,
init: RequestInit = {},
): Promise<T> {
const headers = new Headers(init.headers); const headers = new Headers(init.headers);
headers.set("Accept", "application/json"); headers.set('Accept', 'application/json');
const response = await fetch(input, { const response = await fetch(input, {
...init, ...init,
@@ -40,51 +37,31 @@ export async function getJson<T>(
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
function sendJson<T>( function sendJson<T>(method: 'POST' | 'PUT' | 'PATCH' | 'DELETE', input: RequestInfo | URL, body?: unknown, init: RequestInit = {}) {
method: "POST" | "PUT" | "PATCH" | "DELETE",
input: RequestInfo | URL,
body?: unknown,
init: RequestInit = {},
) {
const headers = new Headers(init.headers); const headers = new Headers(init.headers);
headers.set("Content-Type", "application/json"); headers.set('Content-Type', 'application/json');
return getJson<T>(input, { return getJson<T>(input, {
...init, ...init,
method, method,
headers, headers,
credentials: "same-origin", credentials: 'same-origin',
body: body === undefined ? undefined : JSON.stringify(body), body: body === undefined ? undefined : JSON.stringify(body),
}); });
} }
export function postJson<T>( export function postJson<T>(input: RequestInfo | URL, body?: unknown, init: RequestInit = {}) {
input: RequestInfo | URL, return sendJson<T>('POST', input, body, init);
body?: unknown,
init: RequestInit = {},
) {
return sendJson<T>("POST", input, body, init);
} }
export function patchJson<T>( export function patchJson<T>(input: RequestInfo | URL, body?: unknown, init: RequestInit = {}) {
input: RequestInfo | URL, return sendJson<T>('PATCH', input, body, init);
body?: unknown,
init: RequestInit = {},
) {
return sendJson<T>("PATCH", input, body, init);
} }
export function putJson<T>( export function putJson<T>(input: RequestInfo | URL, body?: unknown, init: RequestInit = {}) {
input: RequestInfo | URL, return sendJson<T>('PUT', input, body, init);
body?: unknown,
init: RequestInit = {},
) {
return sendJson<T>("PUT", input, body, init);
} }
export function deleteJson<T>( export function deleteJson<T>(input: RequestInfo | URL, init: RequestInit = {}) {
input: RequestInfo | URL, return sendJson<T>('DELETE', input, undefined, init);
init: RequestInit = {},
) {
return sendJson<T>("DELETE", input, undefined, init);
} }
+10 -10
View File
@@ -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 = { export type Monitor = {
id: number; id: number;
@@ -66,34 +66,34 @@ export type StatsWindow = {
}; };
export type MonitorStats = { export type MonitorStats = {
windows: Record<"24h" | "7d" | "30d" | "90d", StatsWindow>; windows: Record<'24h' | '7d' | '30d' | '90d', StatsWindow>;
}; };
export function listMonitors(signal?: AbortSignal) { export function listMonitors(signal?: AbortSignal) {
return getJson<{ monitors: Monitor[] }>("/api/monitors", { return getJson<{ monitors: Monitor[] }>('/api/monitors', {
signal, signal,
credentials: "same-origin", credentials: 'same-origin',
}); });
} }
export function getMonitor(id: number, signal?: AbortSignal) { 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) { 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) { export function getMonitorStats(id: number, signal?: AbortSignal) {
return getJson<MonitorStats>(`/api/monitors/${id}/stats`, { signal, credentials: "same-origin" }); return getJson<MonitorStats>(`/api/monitors/${id}/stats`, { signal, credentials: 'same-origin' });
} }
export function listIncidents(id: number, limit = 50, signal?: AbortSignal) { 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) { 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<MonitorInput>) { export function updateMonitor(id: number, input: Partial<MonitorInput>) {
+6 -6
View File
@@ -1,4 +1,4 @@
import { getJson, postJson, putJson } from "./http"; import { getJson, postJson, putJson } from './http';
export type NotificationSettings = { export type NotificationSettings = {
id: number; id: number;
@@ -8,19 +8,19 @@ export type NotificationSettings = {
updatedAt: string | null; updatedAt: string | null;
}; };
export type NotificationSettingsInput = Pick<NotificationSettings, "webhookUrl" | "webhookEnabled">; export type NotificationSettingsInput = Pick<NotificationSettings, 'webhookUrl' | 'webhookEnabled'>;
export function getNotificationSettings(signal?: AbortSignal) { export function getNotificationSettings(signal?: AbortSignal) {
return getJson<{ settings: NotificationSettings }>("/api/settings/notifications", { return getJson<{ settings: NotificationSettings }>('/api/settings/notifications', {
signal, signal,
credentials: "same-origin", credentials: 'same-origin',
}); });
} }
export function updateNotificationSettings(input: NotificationSettingsInput) { export function updateNotificationSettings(input: NotificationSettingsInput) {
return putJson<{ settings: NotificationSettings }>("/api/settings/notifications", input); return putJson<{ settings: NotificationSettings }>('/api/settings/notifications', input);
} }
export function testNotificationWebhook() { export function testNotificationWebhook() {
return postJson<{ ok: true }>("/api/settings/notifications/test"); return postJson<{ ok: true }>('/api/settings/notifications/test');
} }
+4 -4
View File
@@ -1,7 +1,7 @@
import { getJson } from "./http"; import { getJson } from './http';
export type PublicServiceStatus = "up" | "down" | "unknown"; export type PublicServiceStatus = 'up' | 'down' | 'unknown';
export type PublicOverallStatus = "operational" | "degraded" | "down"; export type PublicOverallStatus = 'operational' | 'degraded' | 'down';
export type PublicService = { export type PublicService = {
id: number; id: number;
@@ -22,5 +22,5 @@ export type PublicStatus = {
}; };
export function getStatus(signal?: AbortSignal) { export function getStatus(signal?: AbortSignal) {
return getJson<PublicStatus>("/api/status", { signal }); return getJson<PublicStatus>('/api/status', { signal });
} }
+5 -5
View File
@@ -1,7 +1,7 @@
import { type ReactNode, useEffect } from "react"; import { type ReactNode, useEffect } from 'react';
import { Zap } from "lucide-react"; import { Zap } from 'lucide-react';
import { navigate } from "../lib/router"; import { navigate } from '../lib/router';
import { useSessionQuery } from "../queries/auth"; import { useSessionQuery } from '../queries/auth';
function FullPageLoading() { function FullPageLoading() {
return ( return (
@@ -18,7 +18,7 @@ export function RequireAuth({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
if (!sessionQuery.isPending && !authenticated) { if (!sessionQuery.isPending && !authenticated) {
navigate("/login", { replace: true }); navigate('/login', { replace: true });
} }
}, [sessionQuery.isPending, authenticated]); }, [sessionQuery.isPending, authenticated]);
+5 -7
View File
@@ -1,21 +1,19 @@
import { Database } from "lucide-react"; import { Database } from 'lucide-react';
import { useState } from "react"; import { useState } from 'react';
type SiteIconProps = { type SiteIconProps = {
monitorId: number; 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); const [failed, setFailed] = useState(false);
if (failed) return <Database aria-hidden="true" />; if (failed) return <Database aria-hidden="true" />;
return ( return (
<img <img
src={favicon === "public" src={favicon === 'public' ? `/api/status/${monitorId}/favicon` : `/api/monitors/${monitorId}/favicon`}
? `/api/status/${monitorId}/favicon`
: `/api/monitors/${monitorId}/favicon`}
alt="" alt=""
width={22} width={22}
height={22} height={22}
+18 -24
View File
@@ -1,46 +1,44 @@
import type { PublicService } from "../api/status"; import type { PublicService } from '../api/status';
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
const HISTORY_DAYS = 90; const HISTORY_DAYS = 90;
const DAYS_PER_SEGMENT = 2; const DAYS_PER_SEGMENT = 2;
const HISTORY_SEGMENTS = HISTORY_DAYS / DAYS_PER_SEGMENT; const HISTORY_SEGMENTS = HISTORY_DAYS / DAYS_PER_SEGMENT;
type HistoryEntry = PublicService["history"][number]; type HistoryEntry = PublicService['history'][number];
function dayClass(uptimePct: number | null) { function dayClass(uptimePct: number | null) {
if (uptimePct === null) return "is-empty"; if (uptimePct === null) return 'is-empty';
if (uptimePct === 100) return "is-up"; if (uptimePct === 100) return 'is-up';
if (uptimePct === 0) return "is-down"; if (uptimePct === 0) return 'is-down';
return "is-partial"; return 'is-partial';
} }
function formatDay(day: number) { function formatDay(day: number) {
return new Intl.DateTimeFormat("en", { return new Intl.DateTimeFormat('en', {
month: "short", month: 'short',
day: "numeric", day: 'numeric',
year: "numeric", year: 'numeric',
timeZone: "UTC", timeZone: 'UTC',
}).format(new Date(day)); }).format(new Date(day));
} }
function segmentTitle(startDay: number, endDay: number, uptimePct: number | null) { function segmentTitle(startDay: number, endDay: number, uptimePct: number | null) {
const dateRange = `${formatDay(startDay)} ${formatDay(endDay)}`; const dateRange = `${formatDay(startDay)} ${formatDay(endDay)}`;
return `${dateRange}: ${uptimePct === null ? "No data" : `${uptimePct.toFixed(1)}% uptime`}`; return `${dateRange}: ${uptimePct === null ? 'No data' : `${uptimePct.toFixed(1)}% uptime`}`;
} }
export function StatusHistoryBar({ history }: { history: HistoryEntry[] }) { export function StatusHistoryBar({ history }: { history: HistoryEntry[] }) {
const historyByDay = new Map(history.map((entry) => [entry.day, entry.uptimePct])); const historyByDay = new Map(history.map((entry) => [entry.day, entry.uptimePct]));
const now = new Date(); const now = new Date();
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); 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 startDay = today - (HISTORY_DAYS - index * DAYS_PER_SEGMENT - 1) * DAY_MS;
const endDay = startDay + (DAYS_PER_SEGMENT - 1) * DAY_MS; const endDay = startDay + (DAYS_PER_SEGMENT - 1) * DAY_MS;
const values = Array.from({ length: DAYS_PER_SEGMENT }, (_, dayIndex) => const values = Array.from({ length: DAYS_PER_SEGMENT }, (_day, dayIndex) => historyByDay.get(startDay + dayIndex * DAY_MS)).filter(
historyByDay.get(startDay + dayIndex * DAY_MS), (value): value is number => value !== undefined && value !== null,
).filter((value): value is number => value !== undefined && value !== null); );
const uptimePct = values.length === 0 const uptimePct = values.length === 0 ? null : Math.round((values.reduce((sum, value) => sum + value, 0) / values.length) * 10) / 10;
? null
: Math.round((values.reduce((sum, value) => sum + value, 0) / values.length) * 10) / 10;
return { startDay, endDay, uptimePct }; return { startDay, endDay, uptimePct };
}); });
@@ -48,11 +46,7 @@ export function StatusHistoryBar({ history }: { history: HistoryEntry[] }) {
<div className="status-history"> <div className="status-history">
<div className="uptime-days" aria-label="90-day uptime shown in 45 two-day segments"> <div className="uptime-days" aria-label="90-day uptime shown in 45 two-day segments">
{segments.map(({ startDay, endDay, uptimePct }) => ( {segments.map(({ startDay, endDay, uptimePct }) => (
<span <span className={`uptime-day ${dayClass(uptimePct)}`} key={startDay} title={segmentTitle(startDay, endDay, uptimePct)} />
className={`uptime-day ${dayClass(uptimePct)}`}
key={startDay}
title={segmentTitle(startDay, endDay, uptimePct)}
/>
))} ))}
</div> </div>
<div className="uptime-days-caption" aria-hidden="true"> <div className="uptime-days-caption" aria-hidden="true">
@@ -1,15 +1,6 @@
import { useEffect, useRef } from "react"; import { useState } from 'react';
import { import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, type TooltipContentProps } from 'recharts';
Area, import type { Check } from '../../api/monitors';
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
type TooltipContentProps,
} from "recharts";
import type { Check } from "../../api/monitors";
type LatencyDatum = { type LatencyDatum = {
t: string; t: string;
@@ -25,13 +16,13 @@ function LatencyTooltip({ active, payload }: TooltipContentProps) {
<div className="chart-tooltip"> <div className="chart-tooltip">
<time>{new Date(point.t).toLocaleString()}</time> <time>{new Date(point.t).toLocaleString()}</time>
<strong>{point.latency} ms</strong> <strong>{point.latency} ms</strong>
<span className={point.ok ? "is-up" : "is-down"}>{point.ok ? "Up" : "Down"}</span> <span className={point.ok ? 'is-up' : 'is-down'}>{point.ok ? 'Up' : 'Down'}</span>
</div> </div>
); );
} }
export function LatencySparkline({ checks }: { checks: Check[] }) { export function LatencySparkline({ checks }: { checks: Check[] }) {
const hasAnimated = useRef(false); const [hasAnimated, setHasAnimated] = useState(false);
const data: LatencyDatum[] = checks.toReversed().map((check) => ({ const data: LatencyDatum[] = checks.toReversed().map((check) => ({
t: check.checkedAt, t: check.checkedAt,
latency: check.latencyMs, latency: check.latencyMs,
@@ -39,21 +30,13 @@ export function LatencySparkline({ checks }: { checks: Check[] }) {
})); }));
const values = data.map((point) => point.latency); const values = data.map((point) => point.latency);
useEffect(() => {
if (data.length >= 2) hasAnimated.current = true;
}, [data.length]);
if (data.length < 2) return <div className="chart-empty">More checks are needed to draw latency.</div>; if (data.length < 2) return <div className="chart-empty">More checks are needed to draw latency.</div>;
const minimum = Math.min(...values); const minimum = Math.min(...values);
const maximum = Math.max(...values); const maximum = Math.max(...values);
return ( return (
<div <div className="sparkline-wrap" role="img" aria-label={`Latency from ${minimum} to ${maximum} milliseconds`}>
className="sparkline-wrap"
role="img"
aria-label={`Latency from ${minimum} to ${maximum} milliseconds`}
>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 10, right: 4, bottom: 4, left: 4 }}> <AreaChart data={data} margin={{ top: 10, right: 4, bottom: 4, left: 4 }}>
<defs> <defs>
@@ -64,26 +47,26 @@ export function LatencySparkline({ checks }: { checks: Check[] }) {
</defs> </defs>
<CartesianGrid vertical={false} stroke="#eeeeee" strokeDasharray="3 5" /> <CartesianGrid vertical={false} stroke="#eeeeee" strokeDasharray="3 5" />
<XAxis dataKey="t" hide /> <XAxis dataKey="t" hide />
<YAxis hide domain={["dataMin", "dataMax"]} /> <YAxis hide domain={['dataMin', 'dataMax']} />
<Tooltip <Tooltip content={LatencyTooltip} cursor={{ stroke: '#9adfc1', strokeDasharray: '3 4' }} isAnimationActive={false} />
content={LatencyTooltip}
cursor={{ stroke: "#9adfc1", strokeDasharray: "3 4" }}
isAnimationActive={false}
/>
<Area <Area
type="monotone" type="monotone"
dataKey="latency" dataKey="latency"
stroke="#24b47e" stroke="#24b47e"
strokeWidth={2.5} strokeWidth={2.5}
fill="url(#latency-fill)" fill="url(#latency-fill)"
activeDot={{ r: 4, fill: "#24b47e", stroke: "#fff", strokeWidth: 2 }} activeDot={{ r: 4, fill: '#24b47e', stroke: '#fff', strokeWidth: 2 }}
isAnimationActive={!hasAnimated.current} isAnimationActive={!hasAnimated}
animationDuration={700} animationDuration={700}
animationEasing="ease-out" animationEasing="ease-out"
onAnimationEnd={() => setHasAnimated(true)}
/> />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
<div className="chart-scale"><span>{maximum} ms</span><span>{minimum} ms</span></div> <div className="chart-scale">
<span>{maximum} ms</span>
<span>{minimum} ms</span>
</div>
</div> </div>
); );
} }
+17 -31
View File
@@ -1,15 +1,6 @@
import { useEffect, useRef } from "react"; import { useState } from 'react';
import { import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis, type TooltipContentProps } from 'recharts';
Bar, import type { Check } from '../../api/monitors';
BarChart,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
type TooltipContentProps,
} from "recharts";
import type { Check } from "../../api/monitors";
type UptimeDatum = { type UptimeDatum = {
t: string; t: string;
@@ -25,13 +16,13 @@ function UptimeTooltip({ active, payload }: TooltipContentProps) {
return ( return (
<div className="chart-tooltip chart-tooltip-compact"> <div className="chart-tooltip chart-tooltip-compact">
<time>{new Date(point.t).toLocaleString()}</time> <time>{new Date(point.t).toLocaleString()}</time>
<span className={point.ok ? "is-up" : "is-down"}>{point.ok ? "Up" : "Down"}</span> <span className={point.ok ? 'is-up' : 'is-down'}>{point.ok ? 'Up' : 'Down'}</span>
</div> </div>
); );
} }
export function UptimeBar({ checks }: { checks: Check[] }) { export function UptimeBar({ checks }: { checks: Check[] }) {
const hasAnimated = useRef(false); const [hasAnimated, setHasAnimated] = useState(false);
const data: UptimeDatum[] = checks.toReversed().map((check) => ({ const data: UptimeDatum[] = checks.toReversed().map((check) => ({
t: check.checkedAt, t: check.checkedAt,
v: 1, v: 1,
@@ -39,44 +30,39 @@ export function UptimeBar({ checks }: { checks: Check[] }) {
id: check.id, id: check.id,
})); }));
useEffect(() => {
if (data.length > 0) hasAnimated.current = true;
}, [data.length]);
if (data.length === 0) return <div className="chart-empty">No availability checks recorded yet.</div>; if (data.length === 0) return <div className="chart-empty">No availability checks recorded yet.</div>;
const successfulChecks = data.filter((check) => check.ok).length; const successfulChecks = data.filter((check) => check.ok).length;
return ( return (
<div> <div>
<div <div className="uptime-bar" role="img" aria-label={`${successfulChecks} of ${data.length} recent checks succeeded`}>
className="uptime-bar"
role="img"
aria-label={`${successfulChecks} of ${data.length} recent checks succeeded`}
>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={data} barCategoryGap={2} margin={{ top: 10, right: 0, bottom: 0, left: 0 }}> <BarChart data={data} barCategoryGap={2} margin={{ top: 10, right: 0, bottom: 0, left: 0 }}>
<XAxis dataKey="id" hide /> <XAxis dataKey="id" hide />
<YAxis hide domain={[0, 1]} /> <YAxis hide domain={[0, 1]} />
<Tooltip <Tooltip content={UptimeTooltip} cursor={{ fill: 'rgb(23 23 23 / 0.04)' }} isAnimationActive={false} />
content={UptimeTooltip}
cursor={{ fill: "rgb(23 23 23 / 0.04)" }}
isAnimationActive={false}
/>
<Bar <Bar
dataKey="v" dataKey="v"
radius={[2, 2, 0, 0]} radius={[2, 2, 0, 0]}
isAnimationActive={!hasAnimated.current} isAnimationActive={!hasAnimated}
animationDuration={700} animationDuration={700}
animationEasing="ease-out" animationEasing="ease-out"
onAnimationEnd={() => setHasAnimated(true)}
> >
{data.map((point) => ( {data.map((point) => (
<Cell key={point.id} fill={point.ok ? "#3ecf8e" : "#d95c5c"} /> <Cell key={point.id} fill={point.ok ? '#3ecf8e' : '#d95c5c'} />
))} ))}
</Bar> </Bar>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<div className="uptime-legend"><span>Oldest</span><span><i className="legend-up" /> Up <i className="legend-down" /> Down</span><span>Latest</span></div> <div className="uptime-legend">
<span>Oldest</span>
<span>
<i className="legend-up" /> Up <i className="legend-down" /> Down
</span>
<span>Latest</span>
</div>
</div> </div>
); );
} }
+4 -4
View File
@@ -1,8 +1,8 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
import { ApiError } from "../api/http"; import { ApiError } from '../api/http';
import type { SessionResponse } from "../api/auth"; import type { SessionResponse } from '../api/auth';
const sessionQueryKey = ["auth", "session"] as const; const sessionQueryKey = ['auth', 'session'] as const;
function handleAuthError(error: unknown) { function handleAuthError(error: unknown) {
if (error instanceof ApiError && error.status === 401) { if (error instanceof ApiError && error.status === 401) {
+7 -7
View File
@@ -1,12 +1,12 @@
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from 'react';
const subscribe = (listener: () => void) => { const subscribe = (listener: () => void) => {
window.addEventListener("popstate", listener); window.addEventListener('popstate', listener);
return () => window.removeEventListener("popstate", listener); return () => window.removeEventListener('popstate', listener);
}; };
const getPathname = () => window.location.pathname; const getPathname = () => window.location.pathname;
const getServerPathname = () => "/"; const getServerPathname = () => '/';
export function usePathname() { export function usePathname() {
return useSyncExternalStore(subscribe, getPathname, getServerPathname); return useSyncExternalStore(subscribe, getPathname, getServerPathname);
@@ -14,8 +14,8 @@ export function usePathname() {
export function navigate(path: string, options: { replace?: boolean } = {}) { export function navigate(path: string, options: { replace?: boolean } = {}) {
if (path === window.location.pathname) return; if (path === window.location.pathname) return;
const method = options.replace ? "replaceState" : "pushState"; const method = options.replace ? 'replaceState' : 'pushState';
window.history[method](null, "", path); window.history[method](null, '', path);
window.dispatchEvent(new PopStateEvent("popstate")); window.dispatchEvent(new PopStateEvent('popstate'));
window.scrollTo({ top: 0 }); window.scrollTo({ top: 0 });
} }
+8 -8
View File
@@ -1,14 +1,14 @@
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from '@tanstack/react-query';
import { StrictMode } from "react"; import { StrictMode } from 'react';
import { createRoot } from "react-dom/client"; import { createRoot } from 'react-dom/client';
import App from "./App"; import App from './App';
import { queryClient } from "./lib/query-client"; import { queryClient } from './lib/query-client';
import "./styles.css"; import './styles.css';
const root = document.getElementById("root"); const root = document.getElementById('root');
if (!root) { if (!root) {
throw new Error("Root element was not found"); throw new Error('Root element was not found');
} }
createRoot(root).render( createRoot(root).render(
+239 -63
View File
@@ -1,31 +1,21 @@
import { type FormEvent, useState } from "react"; import { type FormEvent, useState } from 'react';
import { import { ArrowRight, Database, History, Pencil, Power, PowerOff, RefreshCw, Trash2, Zap } from 'lucide-react';
ArrowRight, import type { Monitor, MonitorInput, MonitorMethod } from '../api/monitors';
Database, import { SiteIcon } from '../components/SiteIcon';
History, import { useLogoutMutation } from '../queries/auth';
Pencil, import { navigate } from '../lib/router';
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 { import {
useCreateMonitorMutation, useCreateMonitorMutation,
useDeleteMonitorMutation, useDeleteMonitorMutation,
useMonitorsQuery, useMonitorsQuery,
useRunCheckMutation, useRunCheckMutation,
useUpdateMonitorMutation, useUpdateMonitorMutation,
} from "../queries/monitors"; } from '../queries/monitors';
const DEFAULT_INPUT: MonitorInput = { const DEFAULT_INPUT: MonitorInput = {
name: "", name: '',
url: "https://", url: 'https://',
method: "GET", method: 'GET',
expectedStatus: 200, expectedStatus: 200,
intervalSeconds: 300, intervalSeconds: 300,
timeoutMs: 10_000, timeoutMs: 10_000,
@@ -33,17 +23,17 @@ const DEFAULT_INPUT: MonitorInput = {
}; };
function formatCheckedAt(value: string | null) { function formatCheckedAt(value: string | null) {
if (!value) return "Not checked yet"; if (!value) return 'Not checked yet';
return new Intl.DateTimeFormat("en", { return new Intl.DateTimeFormat('en', {
dateStyle: "medium", dateStyle: 'medium',
timeStyle: "short", timeStyle: 'short',
}).format(new Date(value)); }).format(new Date(value));
} }
function monitorStatus(monitor: Monitor) { function monitorStatus(monitor: Monitor) {
if (monitor.lastOk === true) return { label: "Up", className: "online" }; if (monitor.lastOk === true) return { label: 'Up', className: 'online' };
if (monitor.lastOk === false) return { label: "Down", className: "offline" }; if (monitor.lastOk === false) return { label: 'Down', className: 'offline' };
return { label: "Not checked", className: "checking" }; return { label: 'Not checked', className: 'checking' };
} }
function errorMessage(error: unknown, fallback: string) { function errorMessage(error: unknown, fallback: string) {
@@ -119,10 +109,14 @@ export function DashboardPage() {
</a> </a>
<div className="nav-actions"> <div className="nav-actions">
<span className="header-context">Production monitors</span> <span className="header-context">Production monitors</span>
<a className="nav-auth" href="/">View status page</a> <a className="nav-auth" href="/">
<button className="nav-auth" type="button" onClick={() => navigate("/settings")}>Settings</button> View status page
</a>
<button className="nav-auth" type="button" onClick={() => navigate('/settings')}>
Settings
</button>
<button className="nav-auth" type="button" onClick={() => logoutMutation.mutate()} disabled={logoutMutation.isPending}> <button className="nav-auth" type="button" onClick={() => logoutMutation.mutate()} disabled={logoutMutation.isPending}>
{logoutMutation.isPending ? "Signing out…" : "Sign out"} {logoutMutation.isPending ? 'Signing out…' : 'Sign out'}
</button> </button>
</div> </div>
</div> </div>
@@ -136,7 +130,9 @@ export function DashboardPage() {
<p>Track endpoint availability from Cloudflare's edge every five minutes.</p> <p>Track endpoint availability from Cloudflare's edge every five minutes.</p>
</div> </div>
{formOpen ? ( {formOpen ? (
<button className="secondary-button" type="button" onClick={closeForm}>Close form</button> <button className="secondary-button" type="button" onClick={closeForm}>
Close form
</button>
) : monitorsQuery.isSuccess && monitors.length > 0 ? ( ) : monitorsQuery.isSuccess && monitors.length > 0 ? (
<button className="primary-button" type="button" onClick={openCreateForm}> <button className="primary-button" type="button" onClick={openCreateForm}>
Add monitor <ArrowRight /> Add monitor <ArrowRight />
@@ -145,77 +141,254 @@ export function DashboardPage() {
</section> </section>
<section className="metric-grid" aria-label="Monitor summary"> <section className="metric-grid" aria-label="Monitor summary">
<div className="metric-card"><p>Total monitors</p><strong>{monitors.length}</strong><span>{monitors.filter((monitor) => monitor.enabled).length} enabled</span></div> <div className="metric-card">
<div className="metric-card"><p>Currently up</p><strong>{up}</strong><span>Latest checks succeeded</span></div> <p>Total monitors</p>
<div className="metric-card"><p>Currently down</p><strong>{down}</strong><span>Needs attention</span></div> <strong>{monitors.length}</strong>
<span>{monitors.filter((monitor) => monitor.enabled).length} enabled</span>
</div>
<div className="metric-card">
<p>Currently up</p>
<strong>{up}</strong>
<span>Latest checks succeeded</span>
</div>
<div className="metric-card">
<p>Currently down</p>
<strong>{down}</strong>
<span>Needs attention</span>
</div>
</section> </section>
{formOpen && ( {formOpen && (
<section className="monitor-form-panel" aria-labelledby="monitor-form-title"> <section className="monitor-form-panel" aria-labelledby="monitor-form-title">
<div className="form-panel-heading"> <div className="form-panel-heading">
<div><p className="overline">Configuration</p><h2 id="monitor-form-title">{editing ? `Edit ${editing.name}` : "Add a monitor"}</h2></div> <div>
<p className="overline">Configuration</p>
<h2 id="monitor-form-title">{editing ? `Edit ${editing.name}` : 'Add a monitor'}</h2>
</div>
<p>Checks run on the configured schedule, with a minimum interval of five minutes.</p> <p>Checks run on the configured schedule, with a minimum interval of five minutes.</p>
</div> </div>
<form className="monitor-form" onSubmit={handleSubmit}> <form className="monitor-form" onSubmit={handleSubmit}>
<label className="field field-name"><span>Name</span><input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} maxLength={100} required /></label> <label className="field field-name">
<label className="field field-url"><span>URL</span><input type="url" value={form.url} onChange={(event) => setForm({ ...form, url: event.target.value })} placeholder="https://example.com/health" required /></label> <span>Name</span>
<label className="field"><span>Method</span><select value={form.method} onChange={(event) => setForm({ ...form, method: event.target.value as MonitorMethod })}><option>GET</option><option>HEAD</option><option>POST</option></select></label> <input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} maxLength={100} required />
<label className="field"><span>Expected status</span><input type="number" min="100" max="599" value={form.expectedStatus} onChange={(event) => setForm({ ...form, expectedStatus: event.target.valueAsNumber })} required /></label> </label>
<label className="field"><span>Interval</span><select value={form.intervalSeconds} onChange={(event) => setForm({ ...form, intervalSeconds: Number(event.target.value) })}><option value="300">5 minutes</option><option value="900">15 minutes</option><option value="1800">30 minutes</option><option value="3600">1 hour</option><option value="86400">24 hours</option></select></label> <label className="field field-url">
<label className="field"><span>Timeout (ms)</span><input type="number" min="1000" max="30000" step="1000" value={form.timeoutMs} onChange={(event) => setForm({ ...form, timeoutMs: event.target.valueAsNumber })} required /></label> <span>URL</span>
<label className="toggle-field"><input type="checkbox" checked={form.enabled ?? true} onChange={(event) => setForm({ ...form, enabled: event.target.checked })} /><span>Enable scheduled checks</span></label> <input
<label className="toggle-field"><input type="checkbox" checked={form.alertsEnabled ?? true} onChange={(event) => setForm({ ...form, alertsEnabled: event.target.checked })} /><span>Enable incident alerts</span></label> type="url"
value={form.url}
onChange={(event) => setForm({ ...form, url: event.target.value })}
placeholder="https://example.com/health"
required
/>
</label>
<label className="field">
<span>Method</span>
<select value={form.method} onChange={(event) => setForm({ ...form, method: event.target.value as MonitorMethod })}>
<option>GET</option>
<option>HEAD</option>
<option>POST</option>
</select>
</label>
<label className="field">
<span>Expected status</span>
<input
type="number"
min="100"
max="599"
value={form.expectedStatus}
onChange={(event) => setForm({ ...form, expectedStatus: event.target.valueAsNumber })}
required
/>
</label>
<label className="field">
<span>Interval</span>
<select
value={form.intervalSeconds}
onChange={(event) => setForm({ ...form, intervalSeconds: Number(event.target.value) })}
>
<option value="300">5 minutes</option>
<option value="900">15 minutes</option>
<option value="1800">30 minutes</option>
<option value="3600">1 hour</option>
<option value="86400">24 hours</option>
</select>
</label>
<label className="field">
<span>Timeout (ms)</span>
<input
type="number"
min="1000"
max="30000"
step="1000"
value={form.timeoutMs}
onChange={(event) => setForm({ ...form, timeoutMs: event.target.valueAsNumber })}
required
/>
</label>
<label className="toggle-field">
<input
type="checkbox"
checked={form.enabled ?? true}
onChange={(event) => setForm({ ...form, enabled: event.target.checked })}
/>
<span>Enable scheduled checks</span>
</label>
<label className="toggle-field">
<input
type="checkbox"
checked={form.alertsEnabled ?? true}
onChange={(event) => setForm({ ...form, alertsEnabled: event.target.checked })}
/>
<span>Enable incident alerts</span>
</label>
<div className="form-actions compact-actions"> <div className="form-actions compact-actions">
<button className="secondary-button" type="button" onClick={closeForm}>Cancel</button> <button className="secondary-button" type="button" onClick={closeForm}>
<button className="primary-button" type="submit" disabled={formMutation.isPending}>{formMutation.isPending ? "Saving…" : editing ? "Save changes" : "Add monitor"}</button> Cancel
</button>
<button className="primary-button" type="submit" disabled={formMutation.isPending}>
{formMutation.isPending ? 'Saving' : editing ? 'Save changes' : 'Add monitor'}
</button>
</div> </div>
{formMutation.isError && <p className="form-error" role="alert">{errorMessage(formMutation.error, "Unable to save monitor")}</p>} {formMutation.isError && (
<p className="form-error" role="alert">
{errorMessage(formMutation.error, 'Unable to save monitor')}
</p>
)}
</form> </form>
</section> </section>
)} )}
<section className="services-panel" aria-labelledby="monitor-list-title"> <section className="services-panel" aria-labelledby="monitor-list-title">
<div className="panel-heading"> <div className="panel-heading">
<div><h2 id="monitor-list-title">Configured sites</h2><p>Latest result for each monitored endpoint.</p></div> <div>
<button className="icon-button" type="button" onClick={() => void monitorsQuery.refetch()} disabled={monitorsQuery.isFetching} aria-label="Refresh monitors"> <h2 id="monitor-list-title">Configured sites</h2>
<RefreshCw className={monitorsQuery.isFetching ? "is-spinning" : ""} /> <p>Latest result for each monitored endpoint.</p>
</div>
<button
className="icon-button"
type="button"
onClick={() => void monitorsQuery.refetch()}
disabled={monitorsQuery.isFetching}
aria-label="Refresh monitors"
>
<RefreshCw className={monitorsQuery.isFetching ? 'is-spinning' : ''} />
</button> </button>
</div> </div>
{monitorsQuery.isPending ? ( {monitorsQuery.isPending ? (
<div className="monitor-skeleton" aria-label="Loading monitors">{[0, 1, 2].map((item) => <div key={item}><i /><span /><b /></div>)}</div> <div className="monitor-skeleton" aria-label="Loading monitors">
{[0, 1, 2].map((item) => (
<div key={item}>
<i />
<span />
<b />
</div>
))}
</div>
) : monitorsQuery.isError ? ( ) : monitorsQuery.isError ? (
<div className="panel-state error-state"><strong>Monitors could not be loaded</strong><p>{errorMessage(monitorsQuery.error, "Unknown request error")}</p><button className="secondary-button" type="button" onClick={() => void monitorsQuery.refetch()}>Try again</button></div> <div className="panel-state error-state">
<strong>Monitors could not be loaded</strong>
<p>{errorMessage(monitorsQuery.error, 'Unknown request error')}</p>
<button className="secondary-button" type="button" onClick={() => void monitorsQuery.refetch()}>
Try again
</button>
</div>
) : monitors.length === 0 ? ( ) : monitors.length === 0 ? (
<div className="panel-state empty-state"><span className="empty-icon"><Database /></span><strong>No monitors yet</strong><p>Add the first endpoint to start collecting availability checks.</p>{!formOpen && <button className="primary-button" type="button" onClick={openCreateForm}>Add first site <ArrowRight /></button>}</div> <div className="panel-state empty-state">
<span className="empty-icon">
<Database />
</span>
<strong>No monitors yet</strong>
<p>Add the first endpoint to start collecting availability checks.</p>
{!formOpen && (
<button className="primary-button" type="button" onClick={openCreateForm}>
Add first site <ArrowRight />
</button>
)}
</div>
) : ( ) : (
<div className="monitor-list"> <div className="monitor-list">
<div className="services-title"><span>Monitor</span><span>Latest result</span><span>Actions</span></div> <div className="services-title">
<span>Monitor</span>
<span>Latest result</span>
<span>Actions</span>
</div>
{monitors.map((monitor) => { {monitors.map((monitor) => {
const status = monitorStatus(monitor); const status = monitorStatus(monitor);
const checking = checkMutation.isPending && checkMutation.variables === monitor.id; const checking = checkMutation.isPending && checkMutation.variables === monitor.id;
const deleting = deleteMutation.isPending && deleteMutation.variables === monitor.id; const deleting = deleteMutation.isPending && deleteMutation.variables === monitor.id;
const toggling = updateMutation.isPending && updateMutation.variables?.id === monitor.id; const toggling = updateMutation.isPending && updateMutation.variables?.id === monitor.id;
return ( return (
<article className={`service-row ${monitor.enabled ? "" : "is-disabled"}`} key={monitor.id}> <article className={`service-row ${monitor.enabled ? '' : 'is-disabled'}`} key={monitor.id}>
<div className="service-name"><span className="service-icon"><SiteIcon key={monitor.url} monitorId={monitor.id} /></span><div><button className="monitor-name-link" type="button" onClick={() => navigate(`/monitors/${monitor.id}`)}>{monitor.name}</button><small title={monitor.url}>{monitor.url}</small><span className="monitor-meta">{monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m</span></div></div> <div className="service-name">
<div className="monitor-result"><span className={`row-status ${checking ? "checking" : status.className}`}><i />{checking ? "Checking" : status.label}</span><code>{monitor.lastStatusCode === null ? "—" : `HTTP ${monitor.lastStatusCode}`} · {monitor.lastLatencyMs === null ? "—" : `${monitor.lastLatencyMs} ms`}</code><small title={monitor.lastError ?? undefined}>{monitor.lastError ?? formatCheckedAt(monitor.lastCheckedAt)}</small></div> <span className="service-icon">
<SiteIcon key={monitor.url} monitorId={monitor.id} />
</span>
<div>
<button className="monitor-name-link" type="button" onClick={() => navigate(`/monitors/${monitor.id}`)}>
{monitor.name}
</button>
<small title={monitor.url}>{monitor.url}</small>
<span className="monitor-meta">
{monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m
</span>
</div>
</div>
<div className="monitor-result">
<span className={`row-status ${checking ? 'checking' : status.className}`}>
<i />
{checking ? 'Checking' : status.label}
</span>
<code>
{monitor.lastStatusCode === null ? '' : `HTTP ${monitor.lastStatusCode}`} ·{' '}
{monitor.lastLatencyMs === null ? '' : `${monitor.lastLatencyMs} ms`}
</code>
<small title={monitor.lastError ?? undefined}>{monitor.lastError ?? formatCheckedAt(monitor.lastCheckedAt)}</small>
</div>
<div className="row-actions" aria-label={`Actions for ${monitor.name}`}> <div className="row-actions" aria-label={`Actions for ${monitor.name}`}>
<button className="row-action row-action-labeled" type="button" onClick={() => navigate(`/monitors/${monitor.id}`)}> <button className="row-action row-action-labeled" type="button" onClick={() => navigate(`/monitors/${monitor.id}`)}>
<History aria-hidden="true" /> <History aria-hidden="true" />
<span>History</span> <span>History</span>
</button> </button>
<button className="row-action row-action-labeled row-action-accent" type="button" onClick={() => checkMutation.mutate(monitor.id)} disabled={checking} aria-busy={checking}> <button
<RefreshCw className={checking ? "is-spinning" : ""} aria-hidden="true" /> className="row-action row-action-labeled row-action-accent"
<span>{checking ? "Checking" : "Check now"}</span> type="button"
onClick={() => checkMutation.mutate(monitor.id)}
disabled={checking}
aria-busy={checking}
>
<RefreshCw className={checking ? 'is-spinning' : ''} aria-hidden="true" />
<span>{checking ? 'Checking' : 'Check now'}</span>
</button> </button>
<button className="row-action row-action-icon" type="button" onClick={() => openEditForm(monitor)} aria-label={`Edit ${monitor.name}`} title="Edit monitor"> <button
className="row-action row-action-icon"
type="button"
onClick={() => openEditForm(monitor)}
aria-label={`Edit ${monitor.name}`}
title="Edit monitor"
>
<Pencil aria-hidden="true" /> <Pencil aria-hidden="true" />
</button> </button>
<button className="row-action row-action-icon" type="button" onClick={() => 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`}> <button
className="row-action row-action-icon"
type="button"
onClick={() => 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 ? <PowerOff aria-hidden="true" /> : <Power aria-hidden="true" />} {monitor.enabled ? <PowerOff aria-hidden="true" /> : <Power aria-hidden="true" />}
</button> </button>
<button className="row-action row-action-icon danger-action" type="button" onClick={() => handleDelete(monitor)} disabled={deleting} aria-label={`Delete ${monitor.name}`} title="Delete monitor" aria-busy={deleting}> <button
className="row-action row-action-icon danger-action"
type="button"
onClick={() => handleDelete(monitor)}
disabled={deleting}
aria-label={`Delete ${monitor.name}`}
title="Delete monitor"
aria-busy={deleting}
>
<Trash2 aria-hidden="true" /> <Trash2 aria-hidden="true" />
</button> </button>
</div> </div>
@@ -226,7 +399,10 @@ export function DashboardPage() {
)} )}
</section> </section>
</main> </main>
<footer className="dashboard-page-footer"><span>Cloudflare Workers + D1</span><span>Automatic refresh every minute</span></footer> <footer className="dashboard-page-footer">
<span>Cloudflare Workers + D1</span>
<span>Automatic refresh every minute</span>
</footer>
</div> </div>
); );
} }
+12 -16
View File
@@ -1,28 +1,23 @@
import { type FormEvent, useEffect, useState } from "react"; import { type FormEvent, useEffect, useState } from 'react';
import { Zap } from "lucide-react"; import { Zap } from 'lucide-react';
import { navigate } from "../lib/router"; import { navigate } from '../lib/router';
import { useLoginMutation, useSessionQuery } from "../queries/auth"; import { useLoginMutation, useSessionQuery } from '../queries/auth';
export function LoginPage() { export function LoginPage() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState('');
const sessionQuery = useSessionQuery(); const sessionQuery = useSessionQuery();
const loginMutation = useLoginMutation(); const loginMutation = useLoginMutation();
useEffect(() => { useEffect(() => {
if (sessionQuery.data?.authenticated) navigate("/dashboard", { replace: true }); if (sessionQuery.data?.authenticated) navigate('/dashboard', { replace: true });
}, [sessionQuery.data?.authenticated]); }, [sessionQuery.data?.authenticated]);
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
loginMutation.mutate( loginMutation.mutate({ password }, { onSuccess: () => navigate('/dashboard', { replace: true }) });
{ password },
{ onSuccess: () => navigate("/dashboard", { replace: true }) },
);
} }
const errorMessage = loginMutation.error instanceof Error const errorMessage = loginMutation.error instanceof Error ? loginMutation.error.message : 'Unable to sign in';
? loginMutation.error.message
: "Unable to sign in";
return ( return (
<main className="auth-page"> <main className="auth-page">
@@ -49,16 +44,17 @@ export function LoginPage() {
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
minLength={8} minLength={8}
required required
autoFocus
/> />
</div> </div>
{loginMutation.isError && ( {loginMutation.isError && (
<p className="auth-error" role="alert">{errorMessage}</p> <p className="auth-error" role="alert">
{errorMessage}
</p>
)} )}
<button className="auth-submit" type="submit" disabled={loginMutation.isPending}> <button className="auth-submit" type="submit" disabled={loginMutation.isPending}>
{loginMutation.isPending ? "Signing in…" : "Sign in"} {loginMutation.isPending ? 'Signing in…' : 'Sign in'}
</button> </button>
</form> </form>
</section> </section>
+176 -28
View File
@@ -1,18 +1,18 @@
import { ArrowLeft, BellOff, CheckCircle2, Clock3, ExternalLink, RefreshCw, Zap } from "lucide-react"; import { ArrowLeft, BellOff, CheckCircle2, Clock3, ExternalLink, RefreshCw, Zap } from 'lucide-react';
import { LatencySparkline } from "../components/charts/LatencySparkline"; import { LatencySparkline } from '../components/charts/LatencySparkline';
import { UptimeBar } from "../components/charts/UptimeBar"; import { UptimeBar } from '../components/charts/UptimeBar';
import { navigate } from "../lib/router"; import { navigate } from '../lib/router';
import { useLogoutMutation } from "../queries/auth"; import { useLogoutMutation } from '../queries/auth';
import { import {
useMonitorChecksQuery, useMonitorChecksQuery,
useMonitorIncidentsQuery, useMonitorIncidentsQuery,
useMonitorQuery, useMonitorQuery,
useMonitorStatsQuery, useMonitorStatsQuery,
useRunCheckMutation, useRunCheckMutation,
} from "../queries/monitors"; } from '../queries/monitors';
function formatDate(value: string) { 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) { function formatDuration(ms: number | null, startedAt: string) {
@@ -24,9 +24,9 @@ function formatDuration(ms: number | null, startedAt: string) {
} }
function statusDetails(lastOk: boolean | null) { function statusDetails(lastOk: boolean | null) {
if (lastOk === true) return { label: "Operational", className: "online" }; if (lastOk === true) return { label: 'Operational', className: 'online' };
if (lastOk === false) return { label: "Down", className: "offline" }; if (lastOk === false) return { label: 'Down', className: 'offline' };
return { label: "Awaiting first check", className: "checking" }; return { label: 'Awaiting first check', className: 'checking' };
} }
export function MonitorDetailPage({ id }: { id: number }) { export function MonitorDetailPage({ id }: { id: number }) {
@@ -42,40 +42,188 @@ export function MonitorDetailPage({ id }: { id: number }) {
const status = statusDetails(monitor?.lastOk ?? null); const status = statusDetails(monitor?.lastOk ?? null);
const openIncident = incidents.find((incident) => incident.resolvedAt === null); const openIncident = incidents.find((incident) => incident.resolvedAt === null);
if (monitorQuery.isPending) return <div className="full-page-loading"><RefreshCw className="loading-mark" /><p>Loading monitor history</p></div>; if (monitorQuery.isPending)
if (monitorQuery.isError || !monitor) return ( return (
<div className="detail-error"><strong>Monitor not found</strong><p>The requested monitor could not be loaded.</p><button className="secondary-button" onClick={() => navigate("/dashboard")}>Return to dashboard</button></div> <div className="full-page-loading">
); <RefreshCw className="loading-mark" />
<p>Loading monitor history</p>
</div>
);
if (monitorQuery.isError || !monitor)
return (
<div className="detail-error">
<strong>Monitor not found</strong>
<p>The requested monitor could not be loaded.</p>
<button className="secondary-button" onClick={() => navigate('/dashboard')}>
Return to dashboard
</button>
</div>
);
return ( return (
<div className="dashboard-shell"> <div className="dashboard-shell">
<header className="dashboard-header"><div className="dashboard-header-inner"> <header className="dashboard-header">
<button className="brand brand-button" type="button" onClick={() => navigate("/dashboard")}><Zap className="brand-mark" fill="currentColor" /><span>upwatch</span></button> <div className="dashboard-header-inner">
<div className="nav-actions"><button className="nav-auth" onClick={() => navigate("/settings")}>Settings</button><button className="nav-auth" onClick={() => logoutMutation.mutate()} disabled={logoutMutation.isPending}>Sign out</button></div> <button className="brand brand-button" type="button" onClick={() => navigate('/dashboard')}>
</div></header> <Zap className="brand-mark" fill="currentColor" />
<span>upwatch</span>
</button>
<div className="nav-actions">
<button className="nav-auth" onClick={() => navigate('/settings')}>
Settings
</button>
<button className="nav-auth" onClick={() => logoutMutation.mutate()} disabled={logoutMutation.isPending}>
Sign out
</button>
</div>
</div>
</header>
<main className="dashboard-main detail-main"> <main className="dashboard-main detail-main">
<button className="back-link" type="button" onClick={() => navigate("/dashboard")}><ArrowLeft /> All monitors</button> <button className="back-link" type="button" onClick={() => navigate('/dashboard')}>
<ArrowLeft /> All monitors
</button>
<section className="detail-hero"> <section className="detail-hero">
<div className="detail-title"><span className={`status-orb ${status.className}`} /><div><p className="overline">Monitor #{monitor.id}</p><h1>{monitor.name}</h1><a href={monitor.url} target="_blank" rel="noreferrer">{monitor.url}<ExternalLink /></a></div></div> <div className="detail-title">
<div className="detail-actions"><span className={`row-status ${status.className}`}><i />{status.label}</span>{!monitor.alertsEnabled && <span className="muted-alert"><BellOff /> Alerts muted</span>}<button className="primary-button" type="button" onClick={() => checkMutation.mutate(id)} disabled={checkMutation.isPending}>{checkMutation.isPending ? "Checking…" : "Check now"}</button></div> <span className={`status-orb ${status.className}`} />
<div>
<p className="overline">Monitor #{monitor.id}</p>
<h1>{monitor.name}</h1>
<a href={monitor.url} target="_blank" rel="noreferrer">
{monitor.url}
<ExternalLink />
</a>
</div>
</div>
<div className="detail-actions">
<span className={`row-status ${status.className}`}>
<i />
{status.label}
</span>
{!monitor.alertsEnabled && (
<span className="muted-alert">
<BellOff /> Alerts muted
</span>
)}
<button className="primary-button" type="button" onClick={() => checkMutation.mutate(id)} disabled={checkMutation.isPending}>
{checkMutation.isPending ? 'Checking…' : 'Check now'}
</button>
</div>
</section> </section>
<section className="sla-grid" aria-label="Uptime windows"> <section className="sla-grid" aria-label="Uptime windows">
{(["24h", "7d", "30d", "90d"] as const).map((key) => { {(['24h', '7d', '30d', '90d'] as const).map((key) => {
const window = statsQuery.data?.windows[key]; const window = statsQuery.data?.windows[key];
return <article className="sla-card" key={key}><p>{key} uptime</p><strong>{window?.uptimePct == null ? "—" : `${window.uptimePct.toFixed(3)}%`}</strong><span>{window?.totalChecks ?? 0} checks · {window?.avgLatencyMs ?? "—"} ms avg</span></article>; return (
<article className="sla-card" key={key}>
<p>{key} uptime</p>
<strong>{window?.uptimePct == null ? '—' : `${window.uptimePct.toFixed(3)}%`}</strong>
<span>
{window?.totalChecks ?? 0} checks · {window?.avgLatencyMs ?? '—'} ms avg
</span>
</article>
);
})} })}
<article className={`sla-card incident-summary ${openIncident ? "has-incident" : ""}`}><p>Current incident</p><strong>{openIncident ? formatDuration(null, openIncident.startedAt) : "None"}</strong><span>{openIncident ? `Open since ${formatDate(openIncident.startedAt)}` : "Everything is operational"}</span></article> <article className={`sla-card incident-summary ${openIncident ? 'has-incident' : ''}`}>
<p>Current incident</p>
<strong>{openIncident ? formatDuration(null, openIncident.startedAt) : 'None'}</strong>
<span>{openIncident ? `Open since ${formatDate(openIncident.startedAt)}` : 'Everything is operational'}</span>
</article>
</section> </section>
<div className="detail-grid"> <div className="detail-grid">
<section className="data-panel chart-panel"><div className="data-panel-heading"><div><p className="overline">Response time</p><h2>Latency</h2></div><span>Last {checks.length} checks</span></div><LatencySparkline checks={checks} /></section> <section className="data-panel chart-panel">
<section className="data-panel uptime-panel"><div className="data-panel-heading"><div><p className="overline">Availability</p><h2>Recent uptime</h2></div><span>{checks.filter((check) => check.ok).length}/{checks.length} successful</span></div><UptimeBar checks={checks} /></section> <div className="data-panel-heading">
<div>
<p className="overline">Response time</p>
<h2>Latency</h2>
</div>
<span>Last {checks.length} checks</span>
</div>
<LatencySparkline checks={checks} />
</section>
<section className="data-panel uptime-panel">
<div className="data-panel-heading">
<div>
<p className="overline">Availability</p>
<h2>Recent uptime</h2>
</div>
<span>
{checks.filter((check) => check.ok).length}/{checks.length} successful
</span>
</div>
<UptimeBar checks={checks} />
</section>
</div> </div>
<div className="detail-grid lower-grid"> <div className="detail-grid lower-grid">
<section className="data-panel recent-checks-panel"><div className="data-panel-heading"><div><p className="overline">Event stream</p><h2>Recent checks</h2></div><span>{Math.min(checks.length, 20)} shown</span></div><div className="data-table-wrap recent-checks-scroll" role="region" aria-label="Recent checks" tabIndex={0}><table className="data-table"><thead><tr><th>Status</th><th>Response</th><th>Latency</th><th>Checked</th></tr></thead><tbody>{checks.slice(0, 20).map((check) => <tr key={check.id}><td><span className={`row-status ${check.ok ? "online" : "offline"}`}><i />{check.ok ? "Up" : "Down"}</span></td><td><code>{check.statusCode ? `HTTP ${check.statusCode}` : check.error ?? "Failed"}</code></td><td>{check.latencyMs} ms</td><td>{formatDate(check.checkedAt)}</td></tr>)}</tbody></table>{checks.length === 0 && <div className="table-empty">No checks recorded.</div>}</div></section> <section className="data-panel recent-checks-panel">
<section className="data-panel"><div className="data-panel-heading"><div><p className="overline">Downtime</p><h2>Incidents</h2></div><span>{incidents.length} recorded</span></div><div className="incident-list">{incidents.map((incident) => <article className={incident.resolvedAt ? "resolved" : "open"} key={incident.id}><span>{incident.resolvedAt ? <CheckCircle2 /> : <Clock3 />}</span><div><strong>{incident.resolvedAt ? "Resolved incident" : "Incident in progress"}</strong><p>{incident.startError ?? (incident.startStatusCode ? `HTTP ${incident.startStatusCode}` : "Endpoint became unavailable")}</p><small>{formatDate(incident.startedAt)} · {formatDuration(incident.durationMs, incident.startedAt)}</small></div></article>)}{incidents.length === 0 && <div className="table-empty">No downtime incidents recorded.</div>}</div></section> <div className="data-panel-heading">
<div>
<p className="overline">Event stream</p>
<h2>Recent checks</h2>
</div>
<span>{Math.min(checks.length, 20)} shown</span>
</div>
{/* Keyboard focus makes this horizontally scrollable region accessible without a pointer. */}
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
<section className="data-table-wrap recent-checks-scroll" aria-label="Recent checks" tabIndex={0}>
<table className="data-table">
<thead>
<tr>
<th>Status</th>
<th>Response</th>
<th>Latency</th>
<th>Checked</th>
</tr>
</thead>
<tbody>
{checks.slice(0, 20).map((check) => (
<tr key={check.id}>
<td>
<span className={`row-status ${check.ok ? 'online' : 'offline'}`}>
<i />
{check.ok ? 'Up' : 'Down'}
</span>
</td>
<td>
<code>{check.statusCode ? `HTTP ${check.statusCode}` : (check.error ?? 'Failed')}</code>
</td>
<td>{check.latencyMs} ms</td>
<td>{formatDate(check.checkedAt)}</td>
</tr>
))}
</tbody>
</table>
{checks.length === 0 && <div className="table-empty">No checks recorded.</div>}
</section>
</section>
<section className="data-panel">
<div className="data-panel-heading">
<div>
<p className="overline">Downtime</p>
<h2>Incidents</h2>
</div>
<span>{incidents.length} recorded</span>
</div>
<div className="incident-list">
{incidents.map((incident) => (
<article className={incident.resolvedAt ? 'resolved' : 'open'} key={incident.id}>
<span>{incident.resolvedAt ? <CheckCircle2 /> : <Clock3 />}</span>
<div>
<strong>{incident.resolvedAt ? 'Resolved incident' : 'Incident in progress'}</strong>
<p>
{incident.startError ??
(incident.startStatusCode ? `HTTP ${incident.startStatusCode}` : 'Endpoint became unavailable')}
</p>
<small>
{formatDate(incident.startedAt)} · {formatDuration(incident.durationMs, incident.startedAt)}
</small>
</div>
</article>
))}
{incidents.length === 0 && <div className="table-empty">No downtime incidents recorded.</div>}
</div>
</section>
</div> </div>
</main> </main>
</div> </div>
+106 -23
View File
@@ -1,12 +1,16 @@
import { type FormEvent, useState } from "react"; import { type FormEvent, useState } from 'react';
import { ArrowLeft, BellRing, Send, Zap } from "lucide-react"; import { ArrowLeft, BellRing, Send, Zap } from 'lucide-react';
import type { NotificationSettings } from "../api/settings"; import type { NotificationSettings } from '../api/settings';
import { navigate } from "../lib/router"; import { navigate } from '../lib/router';
import { useLogoutMutation } from "../queries/auth"; import { useLogoutMutation } from '../queries/auth';
import { useNotificationSettingsQuery, useTestNotificationWebhookMutation, useUpdateNotificationSettingsMutation } from "../queries/settings"; import {
useNotificationSettingsQuery,
useTestNotificationWebhookMutation,
useUpdateNotificationSettingsMutation,
} from '../queries/settings';
function SettingsForm({ settings }: { settings: NotificationSettings }) { function SettingsForm({ settings }: { settings: NotificationSettings }) {
const [webhookUrl, setWebhookUrl] = useState(settings.webhookUrl ?? ""); const [webhookUrl, setWebhookUrl] = useState(settings.webhookUrl ?? '');
const [webhookEnabled, setWebhookEnabled] = useState(settings.webhookEnabled); const [webhookEnabled, setWebhookEnabled] = useState(settings.webhookEnabled);
const updateMutation = useUpdateNotificationSettingsMutation(); const updateMutation = useUpdateNotificationSettingsMutation();
const testMutation = useTestNotificationWebhookMutation(); const testMutation = useTestNotificationWebhookMutation();
@@ -16,30 +20,109 @@ function SettingsForm({ settings }: { settings: NotificationSettings }) {
updateMutation.mutate({ webhookUrl: webhookUrl.trim() || null, webhookEnabled }); updateMutation.mutate({ webhookUrl: webhookUrl.trim() || null, webhookEnabled });
} }
return <form className="settings-form" onSubmit={submit}> return (
<label className="field"><span>Webhook URL</span><input type="url" value={webhookUrl} onChange={(event) => setWebhookUrl(event.target.value)} placeholder="https://hooks.example.com/services/…" /></label> <form className="settings-form" onSubmit={submit}>
<label className="settings-toggle"><input type="checkbox" checked={webhookEnabled} onChange={(event) => setWebhookEnabled(event.target.checked)} /><span><strong>Enable incident alerts</strong><small>Send a webhook when a monitor goes down and when it recovers.</small></span></label> <label className="field" htmlFor="webhook-url">
<div className="settings-actions"><button className="secondary-button" type="button" onClick={() => testMutation.mutate()} disabled={!settings.webhookUrl || testMutation.isPending}><Send /> {testMutation.isPending ? "Sending…" : "Send test"}</button><button className="primary-button" type="submit" disabled={updateMutation.isPending}>{updateMutation.isPending ? "Saving…" : "Save settings"}</button></div> <span>Webhook URL</span>
{updateMutation.isSuccess && <p className="settings-success">Notification settings saved.</p>} <input
{testMutation.isSuccess && <p className="settings-success">Test webhook delivered successfully.</p>} id="webhook-url"
{(updateMutation.isError || testMutation.isError) && <p className="form-error">{(updateMutation.error ?? testMutation.error)?.message ?? "Request failed"}</p>} type="url"
</form>; value={webhookUrl}
onChange={(event) => setWebhookUrl(event.target.value)}
placeholder="https://hooks.example.com/services/…"
/>
</label>
<label className="settings-toggle" htmlFor="webhook-enabled" aria-label="Enable incident alerts">
<input
id="webhook-enabled"
type="checkbox"
checked={webhookEnabled}
onChange={(event) => setWebhookEnabled(event.target.checked)}
/>
<span>
<strong>Enable incident alerts</strong>
<small>Send a webhook when a monitor goes down and when it recovers.</small>
</span>
</label>
<div className="settings-actions">
<button
className="secondary-button"
type="button"
onClick={() => testMutation.mutate()}
disabled={!settings.webhookUrl || testMutation.isPending}
>
<Send /> {testMutation.isPending ? 'Sending…' : 'Send test'}
</button>
<button className="primary-button" type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving…' : 'Save settings'}
</button>
</div>
{updateMutation.isSuccess && <p className="settings-success">Notification settings saved.</p>}
{testMutation.isSuccess && <p className="settings-success">Test webhook delivered successfully.</p>}
{(updateMutation.isError || testMutation.isError) && (
<p className="form-error">{(updateMutation.error ?? testMutation.error)?.message ?? 'Request failed'}</p>
)}
</form>
);
} }
export function SettingsPage() { export function SettingsPage() {
const settingsQuery = useNotificationSettingsQuery(); const settingsQuery = useNotificationSettingsQuery();
const logoutMutation = useLogoutMutation(); const logoutMutation = useLogoutMutation();
return <div className="dashboard-shell"> return (
<header className="dashboard-header"><div className="dashboard-header-inner"><button className="brand brand-button" onClick={() => navigate("/dashboard")}><Zap className="brand-mark" fill="currentColor" /><span>upwatch</span></button><div className="nav-actions"><span className="header-context">Settings</span><button className="nav-auth" onClick={() => logoutMutation.mutate()}>Sign out</button></div></div></header> <div className="dashboard-shell">
<main className="settings-main"><button className="back-link" type="button" onClick={() => navigate("/dashboard")}><ArrowLeft /> Dashboard</button><section className="settings-heading"><p className="overline">Integrations</p><h1>Notifications</h1><p>Route monitor transitions to Slack, Discord, or any service that accepts JSON webhooks.</p></section> <header className="dashboard-header">
<section className="settings-card"><div className="settings-card-intro"><span><BellRing /></span><div><h2>Incident webhook</h2><p>Upwatch sends a compact JSON payload for down and recovery events. Delivery failures never interrupt monitoring.</p></div></div>{settingsQuery.isPending ? <div className="table-empty">Loading settings</div> : settingsQuery.isError ? <p className="form-error">Unable to load notification settings.</p> : <SettingsForm key={settingsQuery.data.settings.updatedAt ?? "new"} settings={settingsQuery.data.settings} />}</section> <div className="dashboard-header-inner">
<section className="payload-preview"><p className="overline">Payload preview</p><pre>{`{ <button className="brand brand-button" onClick={() => navigate('/dashboard')}>
<Zap className="brand-mark" fill="currentColor" />
<span>upwatch</span>
</button>
<div className="nav-actions">
<span className="header-context">Settings</span>
<button className="nav-auth" onClick={() => logoutMutation.mutate()}>
Sign out
</button>
</div>
</div>
</header>
<main className="settings-main">
<button className="back-link" type="button" onClick={() => navigate('/dashboard')}>
<ArrowLeft /> Dashboard
</button>
<section className="settings-heading">
<p className="overline">Integrations</p>
<h1>Notifications</h1>
<p>Route monitor transitions to Slack, Discord, or any service that accepts JSON webhooks.</p>
</section>
<section className="settings-card">
<div className="settings-card-intro">
<span>
<BellRing />
</span>
<div>
<h2>Incident webhook</h2>
<p>Upwatch sends a compact JSON payload for down and recovery events. Delivery failures never interrupt monitoring.</p>
</div>
</div>
{settingsQuery.isPending ? (
<div className="table-empty">Loading settings</div>
) : settingsQuery.isError ? (
<p className="form-error">Unable to load notification settings.</p>
) : (
<SettingsForm key={settingsQuery.data.settings.updatedAt ?? 'new'} settings={settingsQuery.data.settings} />
)}
</section>
<section className="payload-preview">
<p className="overline">Payload preview</p>
<pre>{`{
"event": "down", "event": "down",
"monitor": { "id": 12, "name": "API", "url": "https://api.example.com" }, "monitor": { "id": 12, "name": "API", "url": "https://api.example.com" },
"statusCode": 500, "statusCode": 500,
"error": "Expected HTTP 200, received 500", "error": "Expected HTTP 200, received 500",
"at": "2026-08-28T03:25:00.000Z" "at": "2026-08-28T03:25:00.000Z"
}`}</pre></section> }`}</pre>
</main> </section>
</div>; </main>
</div>
);
} }
+63 -34
View File
@@ -1,48 +1,48 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react';
import { Activity, CircleCheck, Database, RefreshCw, TriangleAlert, Zap } from "lucide-react"; import { Activity, CircleCheck, Database, RefreshCw, TriangleAlert, Zap } from 'lucide-react';
import type { PublicOverallStatus, PublicServiceStatus } from "../api/status"; import type { PublicOverallStatus, PublicServiceStatus } from '../api/status';
import { SiteIcon } from "../components/SiteIcon"; import { SiteIcon } from '../components/SiteIcon';
import { StatusHistoryBar } from "../components/StatusHistoryBar"; import { StatusHistoryBar } from '../components/StatusHistoryBar';
import { navigate } from "../lib/router"; import { navigate } from '../lib/router';
import { useSessionQuery } from "../queries/auth"; import { useSessionQuery } from '../queries/auth';
import { useStatusQuery } from "../queries/status"; import { useStatusQuery } from '../queries/status';
const OVERALL_COPY: Record<PublicOverallStatus, { title: string; detail: string }> = { const OVERALL_COPY: Record<PublicOverallStatus, { title: string; detail: string }> = {
operational: { operational: {
title: "All systems operational", title: 'All systems operational',
detail: "Every monitored service is responding normally.", detail: 'Every monitored service is responding normally.',
}, },
degraded: { degraded: {
title: "Some systems are degraded", title: 'Some systems are degraded',
detail: "One or more services are currently experiencing disruption.", detail: 'One or more services are currently experiencing disruption.',
}, },
down: { down: {
title: "Major service disruption", title: 'Major service disruption',
detail: "All reporting services are currently unavailable.", detail: 'All reporting services are currently unavailable.',
}, },
}; };
const SERVICE_STATUS: Record<PublicServiceStatus, { label: string; className: string }> = { const SERVICE_STATUS: Record<PublicServiceStatus, { label: string; className: string }> = {
up: { label: "Operational", className: "online" }, up: { label: 'Operational', className: 'online' },
down: { label: "Down", className: "offline" }, down: { label: 'Down', className: 'offline' },
unknown: { label: "Awaiting data", className: "checking" }, unknown: { label: 'Awaiting data', className: 'checking' },
}; };
function errorMessage(error: unknown) { 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) { function relativeUpdate(updatedAt: number, now: number) {
const seconds = Math.max(0, Math.floor((now - updatedAt) / 1_000)); 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`; if (seconds < 60) return `Updated ${seconds}s ago`;
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
return `Updated ${minutes}m ago`; return `Updated ${minutes}m ago`;
} }
function OverallIcon({ status }: { status: PublicOverallStatus }) { function OverallIcon({ status }: { status: PublicOverallStatus }) {
if (status === "operational") return <CircleCheck aria-hidden="true" />; if (status === 'operational') return <CircleCheck aria-hidden="true" />;
if (status === "degraded") return <TriangleAlert aria-hidden="true" />; if (status === 'degraded') return <TriangleAlert aria-hidden="true" />;
return <Activity aria-hidden="true" />; return <Activity aria-hidden="true" />;
} }
@@ -68,9 +68,9 @@ export function StatusPage() {
<button <button
className="status-header-action" className="status-header-action"
type="button" type="button"
onClick={() => 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'}
</button> </button>
</div> </div>
</header> </header>
@@ -85,19 +85,33 @@ export function StatusPage() {
{statusQuery.isPending ? ( {statusQuery.isPending ? (
<div className="status-loading" aria-busy="true" aria-label="Loading service status"> <div className="status-loading" aria-busy="true" aria-label="Loading service status">
<div className="status-banner-skeleton" /> <div className="status-banner-skeleton" />
<div className="status-service-skeleton">{[0, 1, 2].map((item) => <div key={item}><i /><span /><b /></div>)}</div> <div className="status-service-skeleton">
{[0, 1, 2].map((item) => (
<div key={item}>
<i />
<span />
<b />
</div>
))}
</div>
</div> </div>
) : statusQuery.isError || !status ? ( ) : statusQuery.isError || !status ? (
<section className="status-panel-state status-error-state"> <section className="status-panel-state status-error-state">
<span><TriangleAlert /></span> <span>
<TriangleAlert />
</span>
<strong>Status could not be loaded</strong> <strong>Status could not be loaded</strong>
<p>{errorMessage(statusQuery.error)}</p> <p>{errorMessage(statusQuery.error)}</p>
<button className="secondary-button" type="button" onClick={() => void statusQuery.refetch()}>Try again</button> <button className="secondary-button" type="button" onClick={() => void statusQuery.refetch()}>
Try again
</button>
</section> </section>
) : ( ) : (
<> <>
<section className={`status-banner ${status.overall}`} aria-live="polite"> <section className={`status-banner ${status.overall}`} aria-live="polite">
<span className="status-banner-icon"><OverallIcon status={status.overall} /></span> <span className="status-banner-icon">
<OverallIcon status={status.overall} />
</span>
<div> <div>
<h2>{OVERALL_COPY[status.overall].title}</h2> <h2>{OVERALL_COPY[status.overall].title}</h2>
<p>{OVERALL_COPY[status.overall].detail}</p> <p>{OVERALL_COPY[status.overall].detail}</p>
@@ -111,14 +125,22 @@ export function StatusPage() {
<h2 id="public-services-title">Services</h2> <h2 id="public-services-title">Services</h2>
<p>Availability is calculated from checks collected over the last 90 days.</p> <p>Availability is calculated from checks collected over the last 90 days.</p>
</div> </div>
<button className="icon-button" type="button" onClick={() => void statusQuery.refetch()} disabled={statusQuery.isFetching} aria-label="Refresh service status"> <button
<RefreshCw className={statusQuery.isFetching ? "is-spinning" : ""} /> className="icon-button"
type="button"
onClick={() => void statusQuery.refetch()}
disabled={statusQuery.isFetching}
aria-label="Refresh service status"
>
<RefreshCw className={statusQuery.isFetching ? 'is-spinning' : ''} />
</button> </button>
</div> </div>
{status.services.length === 0 ? ( {status.services.length === 0 ? (
<div className="status-panel-state"> <div className="status-panel-state">
<span><Database /></span> <span>
<Database />
</span>
<strong>No public services yet</strong> <strong>No public services yet</strong>
<p>Service health will appear here after monitoring is enabled.</p> <p>Service health will appear here after monitoring is enabled.</p>
</div> </div>
@@ -129,15 +151,20 @@ export function StatusPage() {
return ( return (
<article className="public-service-row" key={service.id}> <article className="public-service-row" key={service.id}>
<div className="public-service-summary"> <div className="public-service-summary">
<span className="service-icon"><SiteIcon monitorId={service.id} favicon="public" /></span> <span className="service-icon">
<SiteIcon monitorId={service.id} favicon="public" />
</span>
<div> <div>
<strong>{service.name}</strong> <strong>{service.name}</strong>
<span className={`row-status ${serviceStatus.className}`}><i />{serviceStatus.label}</span> <span className={`row-status ${serviceStatus.className}`}>
<i />
{serviceStatus.label}
</span>
</div> </div>
</div> </div>
<div className="public-service-uptime"> <div className="public-service-uptime">
<span>90-day uptime</span> <span>90-day uptime</span>
<strong>{service.uptime90d === null ? "—" : `${service.uptime90d.toFixed(1)}%`}</strong> <strong>{service.uptime90d === null ? '—' : `${service.uptime90d.toFixed(1)}%`}</strong>
</div> </div>
<StatusHistoryBar history={service.history} /> <StatusHistoryBar history={service.history} />
</article> </article>
@@ -152,7 +179,9 @@ export function StatusPage() {
<footer className="status-footer"> <footer className="status-footer">
<span>Powered by upwatch</span> <span>Powered by upwatch</span>
<span><i /> Monitoring from Cloudflare's edge</span> <span>
<i /> Monitoring from Cloudflare's edge
</span>
</footer> </footer>
</div> </div>
); );
+5 -5
View File
@@ -1,10 +1,10 @@
import { queryOptions, useMutation, useQuery } from "@tanstack/react-query"; import { queryOptions, useMutation, useQuery } from '@tanstack/react-query';
import { getSession, login, logout, type LoginInput, type SessionResponse } from "../api/auth"; import { getSession, login, logout, type LoginInput, type SessionResponse } from '../api/auth';
import { queryClient } from "../lib/query-client"; import { queryClient } from '../lib/query-client';
export const authKeys = { export const authKeys = {
all: ["auth"] as const, all: ['auth'] as const,
session: () => [...authKeys.all, "session"] as const, session: () => [...authKeys.all, 'session'] as const,
}; };
export const sessionQueryOptions = () => export const sessionQueryOptions = () =>
+4 -4
View File
@@ -1,9 +1,9 @@
import { queryOptions, useQuery } from "@tanstack/react-query"; import { queryOptions, useQuery } from '@tanstack/react-query';
import { getHealth } from "../api/health"; import { getHealth } from '../api/health';
export const healthKeys = { export const healthKeys = {
all: ["health"] as const, all: ['health'] as const,
status: () => [...healthKeys.all, "status"] as const, status: () => [...healthKeys.all, 'status'] as const,
}; };
export const healthQueryOptions = () => export const healthQueryOptions = () =>
+10 -11
View File
@@ -1,4 +1,4 @@
import { queryOptions, useMutation, useQuery } from "@tanstack/react-query"; import { queryOptions, useMutation, useQuery } from '@tanstack/react-query';
import { import {
createMonitor, createMonitor,
deleteMonitor, deleteMonitor,
@@ -10,16 +10,16 @@ import {
runMonitorCheck, runMonitorCheck,
updateMonitor, updateMonitor,
type MonitorInput, type MonitorInput,
} from "../api/monitors"; } from '../api/monitors';
import { queryClient } from "../lib/query-client"; import { queryClient } from '../lib/query-client';
export const monitorKeys = { export const monitorKeys = {
all: ["monitors"] as const, all: ['monitors'] as const,
list: () => [...monitorKeys.all, "list"] as const, list: () => [...monitorKeys.all, 'list'] as const,
detail: (id: number) => [...monitorKeys.all, "detail", id] as const, detail: (id: number) => [...monitorKeys.all, 'detail', id] as const,
checks: (id: number) => [...monitorKeys.all, "checks", id] as const, checks: (id: number) => [...monitorKeys.all, 'checks', id] as const,
stats: (id: number) => [...monitorKeys.all, "stats", id] as const, stats: (id: number) => [...monitorKeys.all, 'stats', id] as const,
incidents: (id: number) => [...monitorKeys.all, "incidents", id] as const, incidents: (id: number) => [...monitorKeys.all, 'incidents', id] as const,
}; };
export const monitorsQueryOptions = () => export const monitorsQueryOptions = () =>
@@ -88,8 +88,7 @@ export function useCreateMonitorMutation() {
export function useUpdateMonitorMutation() { export function useUpdateMonitorMutation() {
return useMutation({ return useMutation({
mutationFn: ({ id, input }: { id: number; input: Partial<MonitorInput> }) => mutationFn: ({ id, input }: { id: number; input: Partial<MonitorInput> }) => updateMonitor(id, input),
updateMonitor(id, input),
onSuccess: invalidateMonitors, onSuccess: invalidateMonitors,
}); });
} }
+5 -5
View File
@@ -1,15 +1,15 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from '@tanstack/react-query';
import { import {
getNotificationSettings, getNotificationSettings,
testNotificationWebhook, testNotificationWebhook,
updateNotificationSettings, updateNotificationSettings,
type NotificationSettingsInput, type NotificationSettingsInput,
} from "../api/settings"; } from '../api/settings';
import { queryClient } from "../lib/query-client"; import { queryClient } from '../lib/query-client';
export const settingsKeys = { export const settingsKeys = {
all: ["settings"] as const, all: ['settings'] as const,
notifications: () => [...settingsKeys.all, "notifications"] as const, notifications: () => [...settingsKeys.all, 'notifications'] as const,
}; };
export function useNotificationSettingsQuery() { export function useNotificationSettingsQuery() {
+3 -3
View File
@@ -1,8 +1,8 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from '@tanstack/react-query';
import { getStatus } from "../api/status"; import { getStatus } from '../api/status';
export const statusKeys = { export const statusKeys = {
all: ["public-status"] as const, all: ['public-status'] as const,
}; };
export function useStatusQuery() { export function useStatusQuery() {
+2076 -403
View File
File diff suppressed because it is too large Load Diff
+10 -20
View File
@@ -1,18 +1,13 @@
import { and, eq, isNull, sql } from "drizzle-orm"; import { and, eq, isNull, sql } from 'drizzle-orm';
import type { Database } from "../db/client"; import type { Database } from '../db/client';
import { checks, incidents, monitors } from "../db/schema"; import { checks, incidents, monitors } from '../db/schema';
import type { CheckResult, Monitor } from "./run-check"; import type { CheckResult, Monitor } from './run-check';
export type IncidentTransition = "opened" | "resolved" | null; export type IncidentTransition = 'opened' | 'resolved' | null;
type BatchStatement = Parameters<Database["batch"]>[0][number]; type BatchStatement = Parameters<Database['batch']>[0][number];
export function buildResultStatements( export function buildResultStatements(db: Database, monitor: Monitor, result: CheckResult, checkedAt: Date) {
db: Database,
monitor: Monitor,
result: CheckResult,
checkedAt: Date,
) {
const statements: BatchStatement[] = [ const statements: BatchStatement[] = [
db.insert(checks).values({ db.insert(checks).values({
monitorId: monitor.id, monitorId: monitor.id,
@@ -47,7 +42,7 @@ export function buildResultStatements(
updatedAt: checkedAt, updatedAt: checkedAt,
}), }),
); );
transition = "opened"; transition = 'opened';
} else if (monitor.lastOk === false && result.ok) { } else if (monitor.lastOk === false && result.ok) {
statements.push( statements.push(
db db
@@ -57,14 +52,9 @@ export function buildResultStatements(
durationMs: sql`${checkedAt.getTime()} - ${incidents.startedAt}`, durationMs: sql`${checkedAt.getTime()} - ${incidents.startedAt}`,
updatedAt: checkedAt, updatedAt: checkedAt,
}) })
.where( .where(and(eq(incidents.monitorId, monitor.id), isNull(incidents.resolvedAt))),
and(
eq(incidents.monitorId, monitor.id),
isNull(incidents.resolvedAt),
),
),
); );
transition = "resolved"; transition = 'resolved';
} }
return { statements, transition }; return { statements, transition };
+5 -9
View File
@@ -1,4 +1,4 @@
import type { monitors } from "../db/schema"; import type { monitors } from '../db/schema';
export type Monitor = typeof monitors.$inferSelect; export type Monitor = typeof monitors.$inferSelect;
@@ -14,9 +14,9 @@ export async function runCheck(monitor: Monitor): Promise<CheckResult> {
try { try {
const response = await fetch(monitor.url, { const response = await fetch(monitor.url, {
method: monitor.method, method: monitor.method,
redirect: "follow", redirect: 'follow',
signal: AbortSignal.timeout(monitor.timeoutMs), 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(); await response.body?.cancel();
const ok = response.status === monitor.expectedStatus; const ok = response.status === monitor.expectedStatus;
@@ -24,18 +24,14 @@ export async function runCheck(monitor: Monitor): Promise<CheckResult> {
ok, ok,
statusCode: response.status, statusCode: response.status,
latencyMs: Date.now() - startedAt, latencyMs: Date.now() - startedAt,
error: ok error: ok ? null : `Expected HTTP ${monitor.expectedStatus}, received ${response.status}`,
? null
: `Expected HTTP ${monitor.expectedStatus}, received ${response.status}`,
}; };
} catch (error) { } catch (error) {
return { return {
ok: false, ok: false,
statusCode: null, statusCode: null,
latencyMs: Date.now() - startedAt, latencyMs: Date.now() - startedAt,
error: error instanceof Error error: error instanceof Error ? error.message.slice(0, 200) : 'Request failed',
? error.message.slice(0, 200)
: "Request failed",
}; };
} }
} }
+20 -19
View File
@@ -1,9 +1,9 @@
import { and, eq, sql } from "drizzle-orm"; import { and, eq, sql } from 'drizzle-orm';
import { getDb } from "../db/client"; import { getDb } from '../db/client';
import { monitors } from "../db/schema"; import { monitors } from '../db/schema';
import { sendIncidentAlert } from "../notifications/webhook"; import { sendIncidentAlert } from '../notifications/webhook';
import { buildResultStatements } from "./persist-result"; import { buildResultStatements } from './persist-result';
import { runCheck } from "./run-check"; import { runCheck } from './run-check';
const MAX_MONITORS_PER_RUN = 40; const MAX_MONITORS_PER_RUN = 40;
const CONCURRENCY = 10; const CONCURRENCY = 10;
@@ -14,10 +14,7 @@ export type DueCheckSummary = {
down: number; down: number;
}; };
export async function runDueChecks( export async function runDueChecks(env: Env, ctx?: Pick<ExecutionContext, 'waitUntil'>): Promise<DueCheckSummary> {
env: Env,
ctx?: Pick<ExecutionContext, "waitUntil">,
): Promise<DueCheckSummary> {
const db = getDb(env); const db = getDb(env);
const now = Date.now(); const now = Date.now();
const due = await db const due = await db
@@ -60,16 +57,20 @@ export async function runDueChecks(
})); }));
const statements = persisted.flatMap((item) => item.statements); 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 ? [] : [ const notifications = persisted.flatMap((item) =>
sendIncidentAlert(env, { item.transition === null
monitor: item.monitor, ? []
kind: item.transition, : [
result: item.result, sendIncidentAlert(env, {
at: item.checkedAt, monitor: item.monitor,
}), kind: item.transition,
]); result: item.result,
at: item.checkedAt,
}),
],
);
if (notifications.length > 0) { if (notifications.length > 0) {
const notificationWork = Promise.all(notifications).then(() => undefined); const notificationWork = Promise.all(notifications).then(() => undefined);
if (ctx) ctx.waitUntil(notificationWork); if (ctx) ctx.waitUntil(notificationWork);
+2 -2
View File
@@ -1,5 +1,5 @@
import { drizzle } from "drizzle-orm/d1"; import { drizzle } from 'drizzle-orm/d1';
import * as schema from "./schema"; import * as schema from './schema';
export const getDb = (env: Env) => drizzle(env.DB, { schema }); export const getDb = (env: Env) => drizzle(env.DB, { schema });
+74 -88
View File
@@ -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", { export const adminCredentials = sqliteTable('admin_credentials', {
id: integer("id").primaryKey(), id: integer('id').primaryKey(),
passwordHash: text("password_hash").notNull(), passwordHash: text('password_hash').notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
}); });
export const notificationSettings = sqliteTable("notification_settings", { export const notificationSettings = sqliteTable('notification_settings', {
id: integer("id").primaryKey(), id: integer('id').primaryKey(),
webhookUrl: text("webhook_url"), webhookUrl: text('webhook_url'),
webhookEnabled: integer("webhook_enabled", { mode: "boolean" }).notNull().default(false), webhookEnabled: integer('webhook_enabled', { mode: 'boolean' }).notNull().default(false),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
}); });
export const sessions = sqliteTable( export const sessions = sqliteTable(
"sessions", 'sessions',
{ {
id: text("id").primaryKey(), id: text('id').primaryKey(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
userAgent: text("user_agent"), 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( export const loginAttempts = sqliteTable(
"login_attempts", 'login_attempts',
{ {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer('id').primaryKey({ autoIncrement: true }),
ipAddress: text("ip_address").notNull(), ipAddress: text('ip_address').notNull(),
attemptedAt: integer("attempted_at", { mode: "timestamp_ms" }).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( export const monitors = sqliteTable(
"monitors", 'monitors',
{ {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer('id').primaryKey({ autoIncrement: true }),
name: text("name").notNull(), name: text('name').notNull(),
url: text("url").notNull(), url: text('url').notNull(),
method: text("method").notNull().default("GET"), method: text('method').notNull().default('GET'),
expectedStatus: integer("expected_status").notNull().default(200), expectedStatus: integer('expected_status').notNull().default(200),
intervalSeconds: integer("interval_seconds").notNull().default(300), intervalSeconds: integer('interval_seconds').notNull().default(300),
timeoutMs: integer("timeout_ms").notNull().default(10_000), timeoutMs: integer('timeout_ms').notNull().default(10_000),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
alertsEnabled: integer("alerts_enabled", { mode: "boolean" }).notNull().default(true), alertsEnabled: integer('alerts_enabled', { mode: 'boolean' }).notNull().default(true),
lastOk: integer("last_ok", { mode: "boolean" }), lastOk: integer('last_ok', { mode: 'boolean' }),
lastStatusCode: integer("last_status_code"), lastStatusCode: integer('last_status_code'),
lastLatencyMs: integer("last_latency_ms"), lastLatencyMs: integer('last_latency_ms'),
lastError: text("last_error"), lastError: text('last_error'),
lastCheckedAt: integer("last_checked_at", { mode: "timestamp_ms" }), lastCheckedAt: integer('last_checked_at', { mode: 'timestamp_ms' }),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
}, },
(table) => [ (table) => [index('monitors_enabled_last_checked_at_idx').on(table.enabled, table.lastCheckedAt)],
index("monitors_enabled_last_checked_at_idx").on(
table.enabled,
table.lastCheckedAt,
),
],
); );
export const checks = sqliteTable( export const checks = sqliteTable(
"checks", 'checks',
{ {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer('id').primaryKey({ autoIncrement: true }),
monitorId: integer("monitor_id") monitorId: integer('monitor_id')
.notNull() .notNull()
.references(() => monitors.id, { onDelete: "cascade" }), .references(() => monitors.id, { onDelete: 'cascade' }),
ok: integer("ok", { mode: "boolean" }).notNull(), ok: integer('ok', { mode: 'boolean' }).notNull(),
statusCode: integer("status_code"), statusCode: integer('status_code'),
latencyMs: integer("latency_ms"), latencyMs: integer('latency_ms'),
error: text("error"), error: text('error'),
checkedAt: integer("checked_at", { mode: "timestamp_ms" }).notNull(), checkedAt: integer('checked_at', { mode: 'timestamp_ms' }).notNull(),
}, },
(table) => [ (table) => [index('checks_monitor_id_checked_at_idx').on(table.monitorId, table.checkedAt)],
index("checks_monitor_id_checked_at_idx").on(
table.monitorId,
table.checkedAt,
),
],
); );
export const incidents = sqliteTable( export const incidents = sqliteTable(
"incidents", 'incidents',
{ {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer('id').primaryKey({ autoIncrement: true }),
monitorId: integer("monitor_id") monitorId: integer('monitor_id')
.notNull() .notNull()
.references(() => monitors.id, { onDelete: "cascade" }), .references(() => monitors.id, { onDelete: 'cascade' }),
startedAt: integer("started_at", { mode: "timestamp_ms" }).notNull(), startedAt: integer('started_at', { mode: 'timestamp_ms' }).notNull(),
resolvedAt: integer("resolved_at", { mode: "timestamp_ms" }), resolvedAt: integer('resolved_at', { mode: 'timestamp_ms' }),
startStatusCode: integer("start_status_code"), startStatusCode: integer('start_status_code'),
startError: text("start_error"), startError: text('start_error'),
durationMs: integer("duration_ms"), durationMs: integer('duration_ms'),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
}, },
(table) => [ (table) => [index('incidents_monitor_id_started_at_idx').on(table.monitorId, table.startedAt)],
index("incidents_monitor_id_started_at_idx").on(table.monitorId, table.startedAt),
],
); );
export const monitorDailyStats = sqliteTable( export const monitorDailyStats = sqliteTable(
"monitor_daily_stats", 'monitor_daily_stats',
{ {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer('id').primaryKey({ autoIncrement: true }),
monitorId: integer("monitor_id") monitorId: integer('monitor_id')
.notNull() .notNull()
.references(() => monitors.id, { onDelete: "cascade" }), .references(() => monitors.id, { onDelete: 'cascade' }),
day: integer("day", { mode: "timestamp_ms" }).notNull(), day: integer('day', { mode: 'timestamp_ms' }).notNull(),
totalChecks: integer("total_checks").notNull(), totalChecks: integer('total_checks').notNull(),
upChecks: integer("up_checks").notNull(), upChecks: integer('up_checks').notNull(),
avgLatencyMs: integer("avg_latency_ms"), avgLatencyMs: integer('avg_latency_ms'),
minLatencyMs: integer("min_latency_ms"), minLatencyMs: integer('min_latency_ms'),
maxLatencyMs: integer("max_latency_ms"), maxLatencyMs: integer('max_latency_ms'),
}, },
(table) => [ (table) => [uniqueIndex('monitor_daily_stats_monitor_id_day_uidx').on(table.monitorId, table.day)],
uniqueIndex("monitor_daily_stats_monitor_id_day_uidx").on(table.monitorId, table.day),
],
); );
+26 -24
View File
@@ -1,19 +1,19 @@
import { Hono } from "hono"; import { Hono } from 'hono';
import { csrf } from "hono/csrf"; import { csrf } from 'hono/csrf';
import { runDueChecks } from "./checks/run-due-checks"; import { runDueChecks } from './checks/run-due-checks';
import authRoutes from "./routes/auth"; import authRoutes from './routes/auth';
import monitorRoutes from "./routes/monitors"; import monitorRoutes from './routes/monitors';
import settingsRoutes from "./routes/settings"; import settingsRoutes from './routes/settings';
import statusRoutes from "./routes/status"; import statusRoutes from './routes/status';
import { cleanupExpiredAuthRecords } from "./scheduled/cleanup"; import { cleanupExpiredAuthRecords } from './scheduled/cleanup';
import { runDailyRollup } from "./scheduled/rollup"; import { runDailyRollup } from './scheduled/rollup';
const app = new Hono<{ Bindings: Env }>(); const app = new Hono<{ Bindings: Env }>();
app.use("/api/*", csrf()); app.use('/api/*', csrf());
app.get("/api/health", async (context) => { app.get('/api/health', async (context) => {
const db = await context.env.DB.prepare("SELECT 1 AS ok").first<{ const db = await context.env.DB.prepare('SELECT 1 AS ok').first<{
ok: number; ok: number;
}>(); }>();
@@ -24,22 +24,24 @@ app.get("/api/health", async (context) => {
}); });
}); });
app.route("/", authRoutes); app.route('/', authRoutes);
app.route("/api/monitors", monitorRoutes); app.route('/api/monitors', monitorRoutes);
app.route("/api/settings", settingsRoutes); app.route('/api/settings', settingsRoutes);
app.route("/api/status", statusRoutes); app.route('/api/status', statusRoutes);
export default { export default {
fetch: app.fetch, fetch: app.fetch,
async scheduled(controller, env, ctx) { async scheduled(controller, env, ctx) {
if (controller.cron === "5 0 * * *") { if (controller.cron === '5 0 * * *') {
const result = await runDailyRollup(env, new Date(controller.scheduledTime)); const result = await runDailyRollup(env, new Date(controller.scheduledTime));
console.log(JSON.stringify({ console.log(
message: "daily rollup completed", JSON.stringify({
cron: controller.cron, message: 'daily rollup completed',
scheduledTime: controller.scheduledTime, cron: controller.cron,
...result, scheduledTime: controller.scheduledTime,
})); ...result,
}),
);
return; return;
} }
@@ -47,7 +49,7 @@ export default {
const result = await runDueChecks(env, ctx); const result = await runDueChecks(env, ctx);
console.log( console.log(
JSON.stringify({ JSON.stringify({
message: "scheduled run completed", message: 'scheduled run completed',
cron: controller.cron, cron: controller.cron,
scheduledTime: controller.scheduledTime, scheduledTime: controller.scheduledTime,
...result, ...result,
+8 -18
View File
@@ -1,4 +1,4 @@
const HASH_ALGORITHM = "SHA-256"; const HASH_ALGORITHM = 'SHA-256';
const HASH_BYTES = 32; const HASH_BYTES = 32;
const SALT_BYTES = 16; const SALT_BYTES = 16;
@@ -7,7 +7,7 @@ const SALT_BYTES = 16;
export const PBKDF2_ITERATIONS = 25_000; export const PBKDF2_ITERATIONS = 25_000;
function bytesToBase64(bytes: Uint8Array) { function bytesToBase64(bytes: Uint8Array) {
let binary = ""; let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte); for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary); return btoa(binary);
} }
@@ -18,18 +18,12 @@ function base64ToBytes(value: string) {
} }
async function derivePassword(plain: string, salt: ArrayBuffer, iterations: number) { async function derivePassword(plain: string, salt: ArrayBuffer, iterations: number) {
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(plain), 'PBKDF2', false, ['deriveBits']);
"raw",
new TextEncoder().encode(plain),
"PBKDF2",
false,
["deriveBits"],
);
return new Uint8Array( return new Uint8Array(
await crypto.subtle.deriveBits( await crypto.subtle.deriveBits(
{ {
name: "PBKDF2", name: 'PBKDF2',
hash: HASH_ALGORITHM, hash: HASH_ALGORITHM,
salt, salt,
iterations, iterations,
@@ -48,12 +42,12 @@ export async function hashPassword(plain: string) {
} }
export async function verifyPassword(plain: string, stored: 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); const iterations = Number(iterationValue);
if ( if (
scheme !== "pbkdf2" || scheme !== 'pbkdf2' ||
digest !== "sha256" || digest !== 'sha256' ||
extra.length > 0 || extra.length > 0 ||
!Number.isSafeInteger(iterations) || !Number.isSafeInteger(iterations) ||
iterations < 1 || iterations < 1 ||
@@ -69,11 +63,7 @@ export async function verifyPassword(plain: string, stored: string) {
const expected = base64ToBytes(hashValue); const expected = base64ToBytes(hashValue);
if (salt.length !== SALT_BYTES || expected.length !== HASH_BYTES) return false; if (salt.length !== SALT_BYTES || expected.length !== HASH_BYTES) return false;
const actual = await derivePassword( const actual = await derivePassword(plain, Uint8Array.from(salt).buffer, iterations);
plain,
Uint8Array.from(salt).buffer,
iterations,
);
const subtle = crypto.subtle as SubtleCrypto & { const subtle = crypto.subtle as SubtleCrypto & {
timingSafeEqual(a: ArrayBufferView, b: ArrayBufferView): boolean; timingSafeEqual(a: ArrayBufferView, b: ArrayBufferView): boolean;
}; };
+7 -7
View File
@@ -1,7 +1,7 @@
import { getCookie } from "hono/cookie"; import { getCookie } from 'hono/cookie';
import { createMiddleware } from "hono/factory"; import { createMiddleware } from 'hono/factory';
import { getDb } from "../db/client"; import { getDb } from '../db/client';
import { hasValidSession, SESSION_COOKIE } from "./session"; import { hasValidSession, SESSION_COOKIE } from './session';
export type AuthVariables = { export type AuthVariables = {
authenticated: true; authenticated: true;
@@ -12,11 +12,11 @@ export const requireAuth = createMiddleware<{
Variables: AuthVariables; Variables: AuthVariables;
}>(async (context, next) => { }>(async (context, next) => {
const token = getCookie(context, SESSION_COOKIE); 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); 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(); await next();
}); });
+15 -28
View File
@@ -1,38 +1,33 @@
import { and, eq, gt } from "drizzle-orm"; import { and, eq, gt } from 'drizzle-orm';
import type { CookieOptions } from "hono/utils/cookie"; import type { CookieOptions } from 'hono/utils/cookie';
import type { Database } from "../db/client"; import type { Database } from '../db/client';
import { sessions } from "../db/schema"; 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; export const SESSION_DURATION_SECONDS = 7 * 24 * 60 * 60;
function bytesToBase64Url(bytes: Uint8Array) { function bytesToBase64Url(bytes: Uint8Array) {
let binary = ""; let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte); 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) { async function sha256Hex(value: string) {
const digest = new Uint8Array( const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)));
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)), return Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('');
);
return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
} }
export function sessionCookieOptions(requestUrl: string): CookieOptions { export function sessionCookieOptions(requestUrl: string): CookieOptions {
return { return {
httpOnly: true, httpOnly: true,
sameSite: "Lax", sameSite: 'Lax',
path: "/", path: '/',
maxAge: SESSION_DURATION_SECONDS, maxAge: SESSION_DURATION_SECONDS,
secure: new URL(requestUrl).protocol === "https:", secure: new URL(requestUrl).protocol === 'https:',
}; };
} }
export async function createSession( export async function createSession(db: Database, userAgent: string | null) {
db: Database,
userAgent: string | null,
) {
const token = bytesToBase64Url(crypto.getRandomValues(new Uint8Array(32))); const token = bytesToBase64Url(crypto.getRandomValues(new Uint8Array(32)));
const now = new Date(); const now = new Date();
@@ -46,19 +41,11 @@ export async function createSession(
return token; return token;
} }
export async function hasValidSession( export async function hasValidSession(db: Database, token: string): Promise<boolean> {
db: Database,
token: string,
): Promise<boolean> {
const [result] = await db const [result] = await db
.select({ id: sessions.id }) .select({ id: sessions.id })
.from(sessions) .from(sessions)
.where( .where(and(eq(sessions.id, await sha256Hex(token)), gt(sessions.expiresAt, new Date())))
and(
eq(sessions.id, await sha256Hex(token)),
gt(sessions.expiresAt, new Date()),
),
)
.limit(1); .limit(1);
return result !== undefined; return result !== undefined;
+41 -42
View File
@@ -1,17 +1,17 @@
import { eq } from "drizzle-orm"; import { eq } from 'drizzle-orm';
import type { CheckResult, Monitor } from "../checks/run-check"; import type { CheckResult, Monitor } from '../checks/run-check';
import { getDb } from "../db/client"; import { getDb } from '../db/client';
import { notificationSettings } from "../db/schema"; import { notificationSettings } from '../db/schema';
export type IncidentAlert = { export type IncidentAlert = {
monitor: Monitor; monitor: Monitor;
kind: "opened" | "resolved"; kind: 'opened' | 'resolved';
result: CheckResult; result: CheckResult;
at: Date; at: Date;
}; };
type WebhookPayload = { type WebhookPayload = {
event: "down" | "recovered" | "test"; event: 'down' | 'recovered' | 'test';
monitor: { id: number; name: string; url: string }; monitor: { id: number; name: string; url: string };
statusCode: number | null; statusCode: number | null;
error: string | null; error: string | null;
@@ -20,10 +20,10 @@ type WebhookPayload = {
async function postWebhook(url: string, payload: WebhookPayload): Promise<boolean> { async function postWebhook(url: string, payload: WebhookPayload): Promise<boolean> {
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: 'POST',
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json',
"User-Agent": "Upwatch/1.0 (+incident webhook)", 'User-Agent': 'Upwatch/1.0 (+incident webhook)',
}, },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
@@ -34,59 +34,58 @@ async function postWebhook(url: string, payload: WebhookPayload): Promise<boolea
export async function sendTestWebhook(url: string): Promise<boolean> { export async function sendTestWebhook(url: string): Promise<boolean> {
try { try {
return await postWebhook(url, { return await postWebhook(url, {
event: "test", event: 'test',
monitor: { id: 0, name: "Upwatch test", url: "https://example.com/health" }, monitor: { id: 0, name: 'Upwatch test', url: 'https://example.com/health' },
statusCode: 200, statusCode: 200,
error: null, error: null,
at: new Date().toISOString(), at: new Date().toISOString(),
}); });
} catch (error) { } catch (error) {
console.error(JSON.stringify({ console.error(
message: "test webhook failed", JSON.stringify({
error: error instanceof Error ? error.message : String(error), message: 'test webhook failed',
})); error: error instanceof Error ? error.message : String(error),
}),
);
return false; return false;
} }
} }
export async function sendIncidentAlert( export async function sendIncidentAlert(env: Env, alert: IncidentAlert): Promise<boolean> {
env: Env,
alert: IncidentAlert,
): Promise<boolean> {
if (!alert.monitor.alertsEnabled) return false; if (!alert.monitor.alertsEnabled) return false;
try { try {
const [settings] = await getDb(env) const [settings] = await getDb(env).select().from(notificationSettings).where(eq(notificationSettings.id, 1)).limit(1);
.select()
.from(notificationSettings)
.where(eq(notificationSettings.id, 1))
.limit(1);
if (!settings?.webhookEnabled || !settings.webhookUrl) return false; if (!settings?.webhookEnabled || !settings.webhookUrl) return false;
const ok = await postWebhook(settings.webhookUrl, { const ok = await postWebhook(settings.webhookUrl, {
event: alert.kind === "opened" ? "down" : "recovered", event: alert.kind === 'opened' ? 'down' : 'recovered',
monitor: { monitor: {
id: alert.monitor.id, id: alert.monitor.id,
name: alert.monitor.name, name: alert.monitor.name,
url: alert.monitor.url, url: alert.monitor.url,
}, },
statusCode: alert.result.statusCode, statusCode: alert.result.statusCode,
error: alert.result.error, error: alert.result.error,
at: alert.at.toISOString(), at: alert.at.toISOString(),
}); });
if (!ok) { if (!ok) {
console.warn(JSON.stringify({ console.warn(
message: "incident webhook returned an error", JSON.stringify({
monitorId: alert.monitor.id, message: 'incident webhook returned an error',
})); monitorId: alert.monitor.id,
}),
);
} }
return ok; return ok;
} catch (error) { } catch (error) {
console.error(JSON.stringify({ console.error(
message: "incident webhook failed", JSON.stringify({
error: error instanceof Error ? error.message : String(error), message: 'incident webhook failed',
monitorId: alert.monitor.id, error: error instanceof Error ? error.message : String(error),
})); monitorId: alert.monitor.id,
}),
);
return false; return false;
} }
} }
+23 -50
View File
@@ -1,17 +1,11 @@
import { and, count, eq, gte } from "drizzle-orm"; import { and, count, eq, gte } from 'drizzle-orm';
import { Hono } from "hono"; import { Hono } from 'hono';
import { deleteCookie, getCookie, setCookie } from "hono/cookie"; import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
import { getDb } from "../db/client"; import { getDb } from '../db/client';
import { adminCredentials, loginAttempts } from "../db/schema"; import { adminCredentials, loginAttempts } from '../db/schema';
import { verifyPassword } from "../lib/password"; import { verifyPassword } from '../lib/password';
import { requireAuth, type AuthVariables } from "../lib/require-auth"; import { requireAuth, type AuthVariables } from '../lib/require-auth';
import { import { createSession, hasValidSession, revokeSession, SESSION_COOKIE, sessionCookieOptions } from '../lib/session';
createSession,
hasValidSession,
revokeSession,
SESSION_COOKIE,
sessionCookieOptions,
} from "../lib/session";
const LOGIN_WINDOW_MS = 15 * 60 * 1000; const LOGIN_WINDOW_MS = 15 * 60 * 1000;
const MAX_FAILED_ATTEMPTS = 10; const MAX_FAILED_ATTEMPTS = 10;
@@ -23,42 +17,28 @@ type LoginBody = {
const authRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>(); 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; let body: LoginBody;
try { try {
body = await context.req.json<LoginBody>(); body = await context.req.json<LoginBody>();
} catch { } catch {
return context.json({ message: "Invalid request body" }, 400); return context.json({ message: 'Invalid request body' }, 400);
} }
if ( if (typeof body.password !== 'string' || body.password.length < 8) {
typeof body.password !== "string" || return context.json({ message: 'Enter a password of at least 8 characters' }, 400);
body.password.length < 8
) {
return context.json(
{ message: "Enter a password of at least 8 characters" },
400,
);
} }
const db = getDb(context.env); 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 cutoff = new Date(Date.now() - LOGIN_WINDOW_MS);
const [attemptResult] = await db const [attemptResult] = await db
.select({ value: count() }) .select({ value: count() })
.from(loginAttempts) .from(loginAttempts)
.where( .where(and(eq(loginAttempts.ipAddress, ipAddress), gte(loginAttempts.attemptedAt, cutoff)));
and(
eq(loginAttempts.ipAddress, ipAddress),
gte(loginAttempts.attemptedAt, cutoff),
),
);
if ((attemptResult?.value ?? 0) >= MAX_FAILED_ATTEMPTS) { if ((attemptResult?.value ?? 0) >= MAX_FAILED_ATTEMPTS) {
return context.json( return context.json({ message: 'Too many login attempts. Try again later' }, 429);
{ message: "Too many login attempts. Try again later" },
429,
);
} }
const [credential] = await db const [credential] = await db
@@ -68,41 +48,34 @@ authRoutes.post("/api/auth/login", async (context) => {
.from(adminCredentials) .from(adminCredentials)
.where(eq(adminCredentials.id, ADMIN_CREDENTIAL_ID)) .where(eq(adminCredentials.id, ADMIN_CREDENTIAL_ID))
.limit(1); .limit(1);
const passwordMatches = credential const passwordMatches = credential ? await verifyPassword(body.password, credential.passwordHash) : false;
? await verifyPassword(body.password, credential.passwordHash)
: false;
if (!credential || !passwordMatches) { if (!credential || !passwordMatches) {
await db.insert(loginAttempts).values({ ipAddress, attemptedAt: new Date() }); 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)); await db.delete(loginAttempts).where(eq(loginAttempts.ipAddress, ipAddress));
const token = await createSession( const token = await createSession(db, context.req.header('User-Agent') ?? null);
db,
context.req.header("User-Agent") ?? null,
);
setCookie(context, SESSION_COOKIE, token, sessionCookieOptions(context.req.url)); setCookie(context, SESSION_COOKIE, token, sessionCookieOptions(context.req.url));
return context.json({ authenticated: true }); 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); const token = getCookie(context, SESSION_COOKIE);
if (token) await revokeSession(getDb(context.env), token); if (token) await revokeSession(getDb(context.env), token);
deleteCookie(context, SESSION_COOKIE, { deleteCookie(context, SESSION_COOKIE, {
path: "/", path: '/',
secure: new URL(context.req.url).protocol === "https:", secure: new URL(context.req.url).protocol === 'https:',
}); });
return context.json({ ok: true }); 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 token = getCookie(context, SESSION_COOKIE);
const authenticated = token const authenticated = token ? await hasValidSession(getDb(context.env), token) : false;
? await hasValidSession(getDb(context.env), token)
: false;
return context.json({ authenticated }); return context.json({ authenticated });
}); });
+147 -154
View File
@@ -1,13 +1,13 @@
import { and, desc, eq, gte, isNull, lt, or, sql } from "drizzle-orm"; import { and, desc, eq, gte, isNull, or, sql } from 'drizzle-orm';
import { Hono } from "hono"; import { Hono } from 'hono';
import { buildResultStatements } from "../checks/persist-result"; import { buildResultStatements } from '../checks/persist-result';
import { runCheck } from "../checks/run-check"; import { runCheck } from '../checks/run-check';
import { getDb } from "../db/client"; import { getDb } from '../db/client';
import { checks, incidents, monitorDailyStats, monitors } from "../db/schema"; import { checks, incidents, monitors } from '../db/schema';
import { requireAuth, type AuthVariables } from "../lib/require-auth"; import { requireAuth, type AuthVariables } from '../lib/require-auth';
import { sendIncidentAlert } from "../notifications/webhook"; import { sendIncidentAlert } from '../notifications/webhook';
type MonitorMethod = "GET" | "HEAD" | "POST"; type MonitorMethod = 'GET' | 'HEAD' | 'POST';
type ParsedMonitorInput = { type ParsedMonitorInput = {
name?: string; name?: string;
@@ -20,11 +20,9 @@ type ParsedMonitorInput = {
alertsEnabled?: boolean; alertsEnabled?: boolean;
}; };
type ParseResult = type ParseResult = { ok: true; value: ParsedMonitorInput } | { ok: false; message: string };
| { ok: true; value: ParsedMonitorInput }
| { ok: false; message: string };
const METHODS = new Set<MonitorMethod>(["GET", "HEAD", "POST"]); const METHODS = new Set<MonitorMethod>(['GET', 'HEAD', 'POST']);
const FAVICON_CACHE_SECONDS = 86_400; const FAVICON_CACHE_SECONDS = 86_400;
const FAVICON_FETCH_TIMEOUT_MS = 5_000; const FAVICON_FETCH_TIMEOUT_MS = 5_000;
const MAX_FAVICON_BYTES = 1024 * 1024; const MAX_FAVICON_BYTES = 1024 * 1024;
@@ -42,31 +40,33 @@ type EdgeCache = {
}; };
function isPrivateHostname(rawHostname: string): boolean { function isPrivateHostname(rawHostname: string): boolean {
const hostname = rawHostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, ""); const hostname = rawHostname
if (hostname === "localhost" || hostname.endsWith(".localhost")) return true; .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)) { if (ipv4.length === 4 && ipv4.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
const [first, second] = ipv4; const [first, second] = ipv4;
return first === 0 return (
|| first === 10 first === 0 ||
|| first === 127 first === 10 ||
|| (first === 169 && second === 254) first === 127 ||
|| (first === 172 && second >= 16 && second <= 31) (first === 169 && second === 254) ||
|| (first === 192 && second === 168); (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; 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); const mappedIpv4 = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
return mappedIpv4 ? isPrivateHostname(mappedIpv4[1]) : false; return mappedIpv4 ? isPrivateHostname(mappedIpv4[1]) : false;
} }
function isSafeRemoteUrl(url: URL) { function isSafeRemoteUrl(url: URL) {
return (url.protocol === "http:" || url.protocol === "https:") return (url.protocol === 'http:' || url.protocol === 'https:') && !url.username && !url.password && !isPrivateHostname(url.hostname);
&& !url.username
&& !url.password
&& !isPrivateHostname(url.hostname);
} }
async function readBodyLimited(response: Response, maximum: number, truncate: boolean) { 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; if (!isSafeRemoteUrl(currentUrl)) return null;
const response = await fetch(currentUrl, { const response = await fetch(currentUrl, {
headers: { headers: {
Accept: "image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,text/html;q=0.5,*/*;q=0.1", 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", 'User-Agent': 'Upwatch Favicon Proxy/1.0',
}, },
redirect: "manual", redirect: 'manual',
signal: controller.signal, signal: controller.signal,
}); });
if ([301, 302, 303, 307, 308].includes(response.status)) { if ([301, 302, 303, 307, 308].includes(response.status)) {
if (response.body) await response.body.cancel().catch(() => undefined); 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; if (!location || redirects === MAX_REDIRECTS) return null;
try { try {
currentUrl = new URL(location, currentUrl); currentUrl = new URL(location, currentUrl);
@@ -141,7 +141,7 @@ async function fetchRemote(url: URL, maximumBytes: number, truncate = false) {
return null; 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 (!truncate && Number.isFinite(declaredLength) && declaredLength > maximumBytes) {
if (response.body) await response.body.cancel().catch(() => undefined); if (response.body) await response.body.cancel().catch(() => undefined);
return null; return null;
@@ -161,9 +161,9 @@ async function fetchRemote(url: URL, maximumBytes: number, truncate = false) {
} }
function imageContentType(headers: Headers) { function imageContentType(headers: Headers) {
const contentType = headers.get("Content-Type")?.split(";", 1)[0].trim().toLowerCase(); const contentType = headers.get('Content-Type')?.split(';', 1)[0].trim().toLowerCase();
if (!contentType || contentType === "application/octet-stream") return "image/x-icon"; if (!contentType || contentType === 'application/octet-stream') return 'image/x-icon';
if (!contentType.startsWith("image/")) { if (!contentType.startsWith('image/')) {
return null; return null;
} }
return contentType; return contentType;
@@ -172,7 +172,7 @@ function imageContentType(headers: Headers) {
function readTagAttribute(tag: string, name: string) { function readTagAttribute(tag: string, name: string) {
const attributes = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g; const attributes = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
for (const match of tag.matchAll(attributes)) { 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; return null;
} }
@@ -182,7 +182,7 @@ function findFaviconUrl(html: string, pageUrl: URL) {
const head = closingHead >= 0 ? html.slice(0, closingHead) : html; const head = closingHead >= 0 ? html.slice(0, closingHead) : html;
let baseUrl = pageUrl; let baseUrl = pageUrl;
const baseTag = head.match(/<base\b[^>]*>/i)?.[0]; const baseTag = head.match(/<base\b[^>]*>/i)?.[0];
const baseHref = baseTag ? readTagAttribute(baseTag, "href") : null; const baseHref = baseTag ? readTagAttribute(baseTag, 'href') : null;
if (baseHref) { if (baseHref) {
try { try {
const candidate = new URL(baseHref, pageUrl); const candidate = new URL(baseHref, pageUrl);
@@ -193,9 +193,9 @@ function findFaviconUrl(html: string, pageUrl: URL) {
} }
for (const match of head.matchAll(/<link\b[^>]*>/gi)) { for (const match of head.matchAll(/<link\b[^>]*>/gi)) {
const rel = readTagAttribute(match[0], "rel")?.toLowerCase().split(/\s+/) ?? []; const rel = readTagAttribute(match[0], 'rel')?.toLowerCase().split(/\s+/) ?? [];
if (!rel.includes("icon") && !rel.includes("apple-touch-icon")) continue; if (!rel.includes('icon') && !rel.includes('apple-touch-icon')) continue;
const href = readTagAttribute(match[0], "href"); const href = readTagAttribute(match[0], 'href');
if (!href) continue; if (!href) continue;
try { try {
const faviconUrl = new URL(href, baseUrl); const faviconUrl = new URL(href, baseUrl);
@@ -217,7 +217,7 @@ export async function resolveFavicon(siteUrl: string): Promise<FaviconResult | n
if (!isSafeRemoteUrl(site)) return null; if (!isSafeRemoteUrl(site)) return null;
const origin = new URL(site.origin); const origin = new URL(site.origin);
const defaultIcon = await fetchRemote(new URL("/favicon.ico", origin), MAX_FAVICON_BYTES); const defaultIcon = await fetchRemote(new URL('/favicon.ico', origin), MAX_FAVICON_BYTES);
if (defaultIcon && defaultIcon.body.byteLength > 0) { if (defaultIcon && defaultIcon.body.byteLength > 0) {
const contentType = imageContentType(defaultIcon.headers); const contentType = imageContentType(defaultIcon.headers);
if (contentType) return { body: defaultIcon.body, contentType }; if (contentType) return { body: defaultIcon.body, contentType };
@@ -225,8 +225,8 @@ export async function resolveFavicon(siteUrl: string): Promise<FaviconResult | n
const page = await fetchRemote(origin, MAX_HEAD_BYTES, true); const page = await fetchRemote(origin, MAX_HEAD_BYTES, true);
if (!page || page.body.byteLength === 0) return null; if (!page || page.body.byteLength === 0) return null;
const pageContentType = page.headers.get("Content-Type")?.toLowerCase(); const pageContentType = page.headers.get('Content-Type')?.toLowerCase();
if (pageContentType && !pageContentType.includes("text/html") && !pageContentType.includes("application/xhtml+xml")) { if (pageContentType && !pageContentType.includes('text/html') && !pageContentType.includes('application/xhtml+xml')) {
return null; return null;
} }
const faviconUrl = findFaviconUrl(new TextDecoder().decode(page.body), page.url); const faviconUrl = findFaviconUrl(new TextDecoder().decode(page.body), page.url);
@@ -239,7 +239,7 @@ export async function resolveFavicon(siteUrl: string): Promise<FaviconResult | n
} }
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === 'object' && value !== null && !Array.isArray(value);
} }
function parseInteger( function parseInteger(
@@ -258,40 +258,40 @@ function parseInteger(
} }
export function parseMonitorInput(body: unknown, partial = false): ParseResult { 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 = {}; const value: ParsedMonitorInput = {};
if (!partial || "name" in body) { if (!partial || 'name' in body) {
if (typeof body.name !== "string" || body.name.trim().length < 1 || body.name.trim().length > 100) { 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" }; return { ok: false, message: 'Name must be between 1 and 100 characters' };
} }
value.name = body.name.trim(); value.name = body.name.trim();
} }
if (!partial || "url" in body) { if (!partial || 'url' in body) {
if (typeof body.url !== "string") { if (typeof body.url !== 'string') {
return { ok: false, message: "Enter a valid http or https URL" }; return { ok: false, message: 'Enter a valid http or https URL' };
} }
try { try {
const url = new URL(body.url); 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(); value.url = url.toString();
} catch { } 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 (!partial || 'method' in body) {
if (typeof body.method !== "string" || !METHODS.has(body.method as MonitorMethod)) { if (typeof body.method !== 'string' || !METHODS.has(body.method as MonitorMethod)) {
return { ok: false, message: "Method must be GET, HEAD, or POST" }; return { ok: false, message: 'Method must be GET, HEAD, or POST' };
} }
value.method = body.method as MonitorMethod; value.method = body.method as MonitorMethod;
} }
for (const [key, label, minimum, maximum] of [ for (const [key, label, minimum, maximum] of [
["expectedStatus", "expectedStatus", 100, 599], ['expectedStatus', 'expectedStatus', 100, 599],
["intervalSeconds", "intervalSeconds", 300, 86_400], ['intervalSeconds', 'intervalSeconds', 300, 86_400],
["timeoutMs", "timeoutMs", 1_000, 30_000], ['timeoutMs', 'timeoutMs', 1_000, 30_000],
] as const) { ] as const) {
if (!partial || key in body) { if (!partial || key in body) {
const parsed = parseInteger(body[key], label, minimum, maximum); 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 ('enabled' in body) {
if (typeof body.enabled !== "boolean") { if (typeof body.enabled !== 'boolean') {
return { ok: false, message: "enabled must be a boolean" }; return { ok: false, message: 'enabled must be a boolean' };
} }
value.enabled = body.enabled; value.enabled = body.enabled;
} }
if ("alertsEnabled" in body) { if ('alertsEnabled' in body) {
if (typeof body.alertsEnabled !== "boolean") { if (typeof body.alertsEnabled !== 'boolean') {
return { ok: false, message: "alertsEnabled must be a boolean" }; return { ok: false, message: 'alertsEnabled must be a boolean' };
} }
value.alertsEnabled = body.alertsEnabled; value.alertsEnabled = body.alertsEnabled;
} }
@@ -335,10 +335,7 @@ type StatsWindow = {
incidentCount: number; incidentCount: number;
}; };
function asStatsWindow( function asStatsWindow(row: { totalChecks: number; upChecks: number; avgLatencyMs: number | null }, incidentCount: number): StatsWindow {
row: { totalChecks: number; upChecks: number; avgLatencyMs: number | null },
incidentCount: number,
): StatsWindow {
return { return {
uptimePct: row.totalChecks > 0 ? Math.round((row.upChecks / row.totalChecks) * 100_000) / 1_000 : null, uptimePct: row.totalChecks > 0 ? Math.round((row.upChecks / row.totalChecks) * 100_000) / 1_000 : null,
totalChecks: row.totalChecks, totalChecks: row.totalChecks,
@@ -350,38 +347,33 @@ function asStatsWindow(
const monitorRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>(); 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); const rows = await getDb(context.env).select().from(monitors).orderBy(monitors.createdAt);
return context.json({ monitors: rows }); return context.json({ monitors: rows });
}); });
monitorRoutes.get("/:id", async (context) => { monitorRoutes.get('/:id', async (context) => {
const id = parseId(context.req.param("id")); const id = parseId(context.req.param('id'));
if (id === null) return context.json({ message: "Monitor not found" }, 404); 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); 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 }); return context.json({ monitor });
}); });
monitorRoutes.get("/:id/checks", async (context) => { monitorRoutes.get('/:id/checks', async (context) => {
const id = parseId(context.req.param("id")); const id = parseId(context.req.param('id'));
if (id === null) return context.json({ message: "Monitor not found" }, 404); if (id === null) return context.json({ message: 'Monitor not found' }, 404);
const limit = parseLimit(context.req.query("limit"), 100, 500); const limit = parseLimit(context.req.query('limit'), 100, 500);
const rows = await getDb(context.env) const rows = await getDb(context.env).select().from(checks).where(eq(checks.monitorId, id)).orderBy(desc(checks.checkedAt)).limit(limit);
.select()
.from(checks)
.where(eq(checks.monitorId, id))
.orderBy(desc(checks.checkedAt))
.limit(limit);
return context.json({ checks: rows }); return context.json({ checks: rows });
}); });
monitorRoutes.get("/:id/incidents", async (context) => { monitorRoutes.get('/:id/incidents', async (context) => {
const id = parseId(context.req.param("id")); const id = parseId(context.req.param('id'));
if (id === null) return context.json({ message: "Monitor not found" }, 404); if (id === null) return context.json({ message: 'Monitor not found' }, 404);
const limit = parseLimit(context.req.query("limit"), 50, 200); const limit = parseLimit(context.req.query('limit'), 50, 200);
const rows = await getDb(context.env) const rows = await getDb(context.env)
.select() .select()
.from(incidents) .from(incidents)
@@ -391,34 +383,38 @@ monitorRoutes.get("/:id/incidents", async (context) => {
return context.json({ incidents: rows }); return context.json({ incidents: rows });
}); });
monitorRoutes.get("/:id/stats", async (context) => { monitorRoutes.get('/:id/stats', async (context) => {
const id = parseId(context.req.param("id")); const id = parseId(context.req.param('id'));
if (id === null) return context.json({ message: "Monitor not found" }, 404); if (id === null) return context.json({ message: 'Monitor not found' }, 404);
const db = getDb(context.env); const db = getDb(context.env);
const [monitor] = await db.select({ id: monitors.id }).from(monitors).where(eq(monitors.id, id)).limit(1); 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 now = Date.now();
const currentDayMs = Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate()); const currentDayMs = Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate());
const windows = [ const windows = [
{ key: "24h", start: now - 24 * 60 * 60 * 1000, raw: true }, { key: '24h', start: now - 24 * 60 * 60 * 1000, raw: true },
{ key: "7d", start: now - 7 * 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: '30d', start: currentDayMs - 29 * 24 * 60 * 60 * 1000, raw: false },
{ key: "90d", start: currentDayMs - 89 * 24 * 60 * 60 * 1000, raw: false }, { key: '90d', start: currentDayMs - 89 * 24 * 60 * 60 * 1000, raw: false },
] as const; ] as const;
const results = await Promise.all(windows.map(async (window) => { const results = await Promise.all(
const [aggregate] = window.raw windows.map(async (window) => {
? await db.select({ const [aggregate] = window.raw
totalChecks: sql<number>`count(*)`, ? await db
upChecks: sql<number>`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`, .select({
avgLatencyMs: sql<number | null>`round(avg(${checks.latencyMs}))`, totalChecks: sql<number>`count(*)`,
}).from(checks).where(and(eq(checks.monitorId, id), gte(checks.checkedAt, new Date(window.start)))) upChecks: sql<number>`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
: await db.select({ avgLatencyMs: sql<number | null>`round(avg(${checks.latencyMs}))`,
totalChecks: sql<number>`coalesce(sum(total_checks), 0)`, })
upChecks: sql<number>`coalesce(sum(up_checks), 0)`, .from(checks)
avgLatencyMs: sql<number | null>`round(sum(avg_latency_ms * total_checks) / nullif(sum(total_checks), 0))`, .where(and(eq(checks.monitorId, id), gte(checks.checkedAt, new Date(window.start))))
}).from(sql`( : await db.select({
totalChecks: sql<number>`coalesce(sum(total_checks), 0)`,
upChecks: sql<number>`coalesce(sum(up_checks), 0)`,
avgLatencyMs: sql<number | null>`round(sum(avg_latency_ms * total_checks) / nullif(sum(total_checks), 0))`,
}).from(sql`(
select total_checks, up_checks, avg_latency_ms select total_checks, up_checks, avg_latency_ms
from monitor_daily_stats from monitor_daily_stats
where monitor_id = ${id} and day >= ${window.start} and day < ${currentDayMs} where monitor_id = ${id} and day >= ${window.start} and day < ${currentDayMs}
@@ -427,28 +423,28 @@ monitorRoutes.get("/:id/stats", async (context) => {
from checks from checks
where monitor_id = ${id} and checked_at >= ${currentDayMs} where monitor_id = ${id} and checked_at >= ${currentDayMs}
)`); )`);
const [incidentAggregate] = await db.select({ count: sql<number>`count(*)` }) const [incidentAggregate] = await db
.from(incidents) .select({ count: sql<number>`count(*)` })
.where(and( .from(incidents)
eq(incidents.monitorId, id), .where(
gte(incidents.startedAt, new Date(window.start)), and(
or(isNull(incidents.resolvedAt), gte(incidents.resolvedAt, new Date(window.start))), eq(incidents.monitorId, id),
)); gte(incidents.startedAt, new Date(window.start)),
return [window.key, asStatsWindow(aggregate, incidentAggregate.count)] as const; 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) => { monitorRoutes.get('/:id/favicon', async (context) => {
const id = parseId(context.req.param("id")); const id = parseId(context.req.param('id'));
if (id === null) return context.json({ message: "Monitor not found" }, 404); if (id === null) return context.json({ message: 'Monitor not found' }, 404);
const [monitor] = await getDb(context.env) const [monitor] = await getDb(context.env).select({ url: monitors.url }).from(monitors).where(eq(monitors.id, id)).limit(1);
.select({ url: monitors.url }) if (!monitor) return context.json({ message: 'Monitor not found' }, 404);
.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`); const cacheKey = new Request(`${new URL(context.req.url).origin}/api/monitors/${id}/favicon`);
let cache: EdgeCache | null = null; let cache: EdgeCache | null = null;
@@ -462,14 +458,14 @@ monitorRoutes.get("/:id/favicon", async (context) => {
} }
const favicon = await resolveFavicon(monitor.url); 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, { const response = new Response(favicon.body, {
headers: { headers: {
"Cache-Control": `public, max-age=${FAVICON_CACHE_SECONDS}`, 'Cache-Control': `public, max-age=${FAVICON_CACHE_SECONDS}`,
"Content-Length": String(favicon.body.byteLength), 'Content-Length': String(favicon.body.byteLength),
"Content-Type": favicon.contentType, 'Content-Type': favicon.contentType,
"X-Content-Type-Options": "nosniff", 'X-Content-Type-Options': 'nosniff',
}, },
}); });
if (cache) { if (cache) {
@@ -478,12 +474,12 @@ monitorRoutes.get("/:id/favicon", async (context) => {
return response; return response;
}); });
monitorRoutes.post("/", async (context) => { monitorRoutes.post('/', async (context) => {
let body: unknown; let body: unknown;
try { try {
body = await context.req.json(); body = await context.req.json();
} catch { } catch {
return context.json({ message: "Invalid request body" }, 400); return context.json({ message: 'Invalid request body' }, 400);
} }
const parsed = parseMonitorInput(body); const parsed = parseMonitorInput(body);
@@ -509,21 +505,21 @@ monitorRoutes.post("/", async (context) => {
return context.json({ monitor }); return context.json({ monitor });
}); });
monitorRoutes.patch("/:id", async (context) => { monitorRoutes.patch('/:id', async (context) => {
const id = parseId(context.req.param("id")); const id = parseId(context.req.param('id'));
if (id === null) return context.json({ message: "Monitor not found" }, 404); if (id === null) return context.json({ message: 'Monitor not found' }, 404);
let body: unknown; let body: unknown;
try { try {
body = await context.req.json(); body = await context.req.json();
} catch { } catch {
return context.json({ message: "Invalid request body" }, 400); return context.json({ message: 'Invalid request body' }, 400);
} }
const parsed = parseMonitorInput(body, true); const parsed = parseMonitorInput(body, true);
if (!parsed.ok) return context.json({ message: parsed.message }, 400); if (!parsed.ok) return context.json({ message: parsed.message }, 400);
if (Object.keys(parsed.value).length === 0) { 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) const [monitor] = await getDb(context.env)
@@ -531,38 +527,35 @@ monitorRoutes.patch("/:id", async (context) => {
.set({ ...parsed.value, updatedAt: new Date() }) .set({ ...parsed.value, updatedAt: new Date() })
.where(eq(monitors.id, id)) .where(eq(monitors.id, id))
.returning(); .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 }); return context.json({ monitor });
}); });
monitorRoutes.delete("/:id", async (context) => { monitorRoutes.delete('/:id', async (context) => {
const id = parseId(context.req.param("id")); const id = parseId(context.req.param('id'));
if (id === null) return context.json({ message: "Monitor not found" }, 404); if (id === null) return context.json({ message: 'Monitor not found' }, 404);
const db = getDb(context.env); const db = getDb(context.env);
const [monitor] = await db.select({ id: monitors.id }).from(monitors).where(eq(monitors.id, id)).limit(1); 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([ await db.batch([db.delete(checks).where(eq(checks.monitorId, id)), db.delete(monitors).where(eq(monitors.id, id))]);
db.delete(checks).where(eq(checks.monitorId, id)),
db.delete(monitors).where(eq(monitors.id, id)),
]);
return context.json({ ok: true }); return context.json({ ok: true });
}); });
monitorRoutes.post("/:id/check", async (context) => { monitorRoutes.post('/:id/check', async (context) => {
const id = parseId(context.req.param("id")); const id = parseId(context.req.param('id'));
if (id === null) return context.json({ message: "Monitor not found" }, 404); if (id === null) return context.json({ message: 'Monitor not found' }, 404);
const db = getDb(context.env); const db = getDb(context.env);
const [monitor] = await db.select().from(monitors).where(eq(monitors.id, id)).limit(1); 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 result = await runCheck(monitor);
const checkedAt = new Date(); const checkedAt = new Date();
const { statements, transition } = buildResultStatements(db, monitor, result, checkedAt); 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) { if (transition) {
await sendIncidentAlert(context.env, { monitor, kind: transition, result, at: checkedAt }); await sendIncidentAlert(context.env, { monitor, kind: transition, result, at: checkedAt });
} }
+25 -33
View File
@@ -1,9 +1,9 @@
import { eq } from "drizzle-orm"; import { eq } from 'drizzle-orm';
import { Hono } from "hono"; import { Hono } from 'hono';
import { getDb } from "../db/client"; import { getDb } from '../db/client';
import { notificationSettings } from "../db/schema"; import { notificationSettings } from '../db/schema';
import { requireAuth, type AuthVariables } from "../lib/require-auth"; import { requireAuth, type AuthVariables } from '../lib/require-auth';
import { sendTestWebhook } from "../notifications/webhook"; import { sendTestWebhook } from '../notifications/webhook';
type NotificationInput = { type NotificationInput = {
webhookUrl: string | null; webhookUrl: string | null;
@@ -11,52 +11,48 @@ type NotificationInput = {
}; };
function parseNotificationInput(value: unknown): NotificationInput | string { function parseNotificationInput(value: unknown): NotificationInput | string {
if (typeof value !== "object" || value === null || Array.isArray(value)) { if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return "Invalid request body"; return 'Invalid request body';
} }
const body = value as Record<string, unknown>; const body = value as Record<string, unknown>;
if (typeof body.webhookEnabled !== "boolean") { if (typeof body.webhookEnabled !== 'boolean') {
return "webhookEnabled must be a boolean"; return 'webhookEnabled must be a boolean';
} }
const rawUrl = typeof body.webhookUrl === "string" ? body.webhookUrl.trim() : body.webhookUrl; 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"; if (rawUrl !== null && typeof rawUrl !== 'string') return 'webhookUrl must be a URL or null';
let webhookUrl = rawUrl || null; let webhookUrl = rawUrl || null;
if (webhookUrl) { if (webhookUrl) {
try { try {
const url = new URL(webhookUrl); 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(); webhookUrl = url.toString();
} catch { } 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 }; return { webhookUrl, webhookEnabled: body.webhookEnabled };
} }
const settingsRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>(); const settingsRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
settingsRoutes.use("*", requireAuth); settingsRoutes.use('*', requireAuth);
settingsRoutes.get("/notifications", async (context) => { settingsRoutes.get('/notifications', async (context) => {
const [settings] = await getDb(context.env) const [settings] = await getDb(context.env).select().from(notificationSettings).where(eq(notificationSettings.id, 1)).limit(1);
.select()
.from(notificationSettings)
.where(eq(notificationSettings.id, 1))
.limit(1);
return context.json({ return context.json({
settings: settings ?? { id: 1, webhookUrl: null, webhookEnabled: false, createdAt: null, updatedAt: null }, 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; let body: unknown;
try { try {
body = await context.req.json(); body = await context.req.json();
} catch { } catch {
return context.json({ message: "Invalid request body" }, 400); return context.json({ message: 'Invalid request body' }, 400);
} }
const input = parseNotificationInput(body); 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 db = getDb(context.env);
const now = new Date(); const now = new Date();
@@ -71,15 +67,11 @@ settingsRoutes.put("/notifications", async (context) => {
return context.json({ settings }); return context.json({ settings });
}); });
settingsRoutes.post("/notifications/test", async (context) => { settingsRoutes.post('/notifications/test', async (context) => {
const [settings] = await getDb(context.env) const [settings] = await getDb(context.env).select().from(notificationSettings).where(eq(notificationSettings.id, 1)).limit(1);
.select() if (!settings?.webhookUrl) return context.json({ message: 'Save a webhook URL first' }, 400);
.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); 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 }); return context.json({ ok: true });
}); });
+47 -46
View File
@@ -1,14 +1,14 @@
import { and, eq, gte, inArray, lt, sql } from "drizzle-orm"; import { and, eq, gte, inArray, lt, sql } from 'drizzle-orm';
import { Hono } from "hono"; import { Hono } from 'hono';
import { getDb } from "../db/client"; import { getDb } from '../db/client';
import { checks, monitorDailyStats, monitors } from "../db/schema"; import { checks, monitorDailyStats, monitors } from '../db/schema';
import { resolveFavicon } from "./monitors"; import { resolveFavicon } from './monitors';
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
const FAVICON_CACHE_SECONDS = 86_400; const FAVICON_CACHE_SECONDS = 86_400;
type ServiceStatus = "up" | "down" | "unknown"; type ServiceStatus = 'up' | 'down' | 'unknown';
type OverallStatus = "operational" | "degraded" | "down"; type OverallStatus = 'operational' | 'degraded' | 'down';
type HistoryEntry = { type HistoryEntry = {
day: number; day: number;
@@ -37,27 +37,27 @@ function roundUptime(upChecks: number, totalChecks: number) {
} }
function serviceStatus(lastOk: boolean | null): ServiceStatus { function serviceStatus(lastOk: boolean | null): ServiceStatus {
if (lastOk === true) return "up"; if (lastOk === true) return 'up';
if (lastOk === false) return "down"; if (lastOk === false) return 'down';
return "unknown"; return 'unknown';
} }
function overallStatus(statuses: ServiceStatus[]): OverallStatus { function overallStatus(statuses: ServiceStatus[]): OverallStatus {
let checked = 0; let checked = 0;
let down = 0; let down = 0;
for (const status of statuses) { for (const status of statuses) {
if (status === "unknown") continue; if (status === 'unknown') continue;
checked += 1; checked += 1;
if (status === "down") down += 1; if (status === 'down') down += 1;
} }
if (down === 0) return "operational"; if (down === 0) return 'operational';
if (down === checked) return "down"; if (down === checked) return 'down';
return "degraded"; return 'degraded';
} }
const statusRoutes = new Hono<{ Bindings: Env }>(); const statusRoutes = new Hono<{ Bindings: Env }>();
statusRoutes.get("/", async (context) => { statusRoutes.get('/', async (context) => {
const db = getDb(context.env); const db = getDb(context.env);
const monitorRows = await db const monitorRows = await db
.select({ .select({
@@ -79,30 +79,31 @@ statusRoutes.get("/", async (context) => {
if (monitorIds.length > 0) { if (monitorIds.length > 0) {
[historicalRows, todayRows] = await Promise.all([ [historicalRows, todayRows] = await Promise.all([
db.select({ db
monitorId: monitorDailyStats.monitorId, .select({
day: monitorDailyStats.day, monitorId: monitorDailyStats.monitorId,
totalChecks: monitorDailyStats.totalChecks, day: monitorDailyStats.day,
upChecks: monitorDailyStats.upChecks, totalChecks: monitorDailyStats.totalChecks,
}) upChecks: monitorDailyStats.upChecks,
})
.from(monitorDailyStats) .from(monitorDailyStats)
.where(and( .where(
inArray(monitorDailyStats.monitorId, monitorIds), and(
gte(monitorDailyStats.day, new Date(cutoff)), inArray(monitorDailyStats.monitorId, monitorIds),
lt(monitorDailyStats.day, new Date(today)), gte(monitorDailyStats.day, new Date(cutoff)),
)) lt(monitorDailyStats.day, new Date(today)),
),
)
.orderBy(monitorDailyStats.day), .orderBy(monitorDailyStats.day),
db.select({ db
monitorId: checks.monitorId, .select({
day: sql<Date>`cast(${today} as integer)`, monitorId: checks.monitorId,
totalChecks: sql<number>`count(*)`, day: sql<Date>`cast(${today} as integer)`,
upChecks: sql<number>`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`, totalChecks: sql<number>`count(*)`,
}) upChecks: sql<number>`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
})
.from(checks) .from(checks)
.where(and( .where(and(inArray(checks.monitorId, monitorIds), gte(checks.checkedAt, new Date(today))))
inArray(checks.monitorId, monitorIds),
gte(checks.checkedAt, new Date(today)),
))
.groupBy(checks.monitorId), .groupBy(checks.monitorId),
]); ]);
} }
@@ -144,15 +145,15 @@ statusRoutes.get("/", async (context) => {
}); });
}); });
statusRoutes.get("/:id/favicon", async (context) => { statusRoutes.get('/:id/favicon', async (context) => {
const id = parseId(context.req.param("id")); const id = parseId(context.req.param('id'));
if (id === null) return context.json({ message: "Service not found" }, 404); if (id === null) return context.json({ message: 'Service not found' }, 404);
const [monitor] = await getDb(context.env) const [monitor] = await getDb(context.env)
.select({ url: monitors.url }) .select({ url: monitors.url })
.from(monitors) .from(monitors)
.where(and(eq(monitors.id, id), eq(monitors.enabled, true))) .where(and(eq(monitors.id, id), eq(monitors.enabled, true)))
.limit(1); .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`); const cacheKey = new Request(`${new URL(context.req.url).origin}/api/status/${id}/favicon`);
let cache: EdgeCache | null = null; let cache: EdgeCache | null = null;
@@ -166,14 +167,14 @@ statusRoutes.get("/:id/favicon", async (context) => {
} }
const favicon = await resolveFavicon(monitor.url); 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, { const response = new Response(favicon.body, {
headers: { headers: {
"Cache-Control": `public, max-age=${FAVICON_CACHE_SECONDS}`, 'Cache-Control': `public, max-age=${FAVICON_CACHE_SECONDS}`,
"Content-Length": String(favicon.body.byteLength), 'Content-Length': String(favicon.body.byteLength),
"Content-Type": favicon.contentType, 'Content-Type': favicon.contentType,
"X-Content-Type-Options": "nosniff", 'X-Content-Type-Options': 'nosniff',
}, },
}); });
if (cache) { if (cache) {
+6 -27
View File
@@ -1,6 +1,6 @@
import { lt } from "drizzle-orm"; import { lt } from 'drizzle-orm';
import { getDb } from "../db/client"; import { getDb } from '../db/client';
import { checks, loginAttempts, monitorDailyStats, sessions } from "../db/schema"; import { checks, loginAttempts, monitorDailyStats, sessions } from '../db/schema';
const LOGIN_ATTEMPT_RETENTION_MS = 60 * 60 * 1000; const LOGIN_ATTEMPT_RETENTION_MS = 60 * 60 * 1000;
const CHECK_RETENTION_MS = 7 * 24 * 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([ await db.batch([
db.delete(sessions).where(lt(sessions.expiresAt, now)), db.delete(sessions).where(lt(sessions.expiresAt, now)),
db db.delete(loginAttempts).where(lt(loginAttempts.attemptedAt, new Date(now.getTime() - LOGIN_ATTEMPT_RETENTION_MS))),
.delete(loginAttempts) db.delete(checks).where(lt(checks.checkedAt, new Date(now.getTime() - CHECK_RETENTION_MS))),
.where( db.delete(monitorDailyStats).where(lt(monitorDailyStats.day, new Date(now.getTime() - DAILY_STATS_RETENTION_MS))),
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),
),
),
]); ]);
} }
+23 -24
View File
@@ -1,16 +1,13 @@
import { and, gte, lt, sql } from "drizzle-orm"; import { and, gte, lt, sql } from 'drizzle-orm';
import { getDb } from "../db/client"; import { getDb } from '../db/client';
import { checks, monitorDailyStats } from "../db/schema"; import { checks, monitorDailyStats } from '../db/schema';
export type DailyRollupSummary = { export type DailyRollupSummary = {
day: string; day: string;
monitors: number; monitors: number;
}; };
export async function runDailyRollup( export async function runDailyRollup(env: Env, now = new Date()): Promise<DailyRollupSummary> {
env: Env,
now = new Date(),
): Promise<DailyRollupSummary> {
const db = getDb(env); const db = getDb(env);
const currentUtcDay = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); const currentUtcDay = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const dayStart = new Date(currentUtcDay - 24 * 60 * 60 * 1000); const dayStart = new Date(currentUtcDay - 24 * 60 * 60 * 1000);
@@ -30,28 +27,30 @@ export async function runDailyRollup(
.groupBy(checks.monitorId); .groupBy(checks.monitorId);
if (rows.length > 0) { if (rows.length > 0) {
const statements = rows.map((row) => db const statements = rows.map((row) =>
.insert(monitorDailyStats) db
.values({ .insert(monitorDailyStats)
monitorId: row.monitorId, .values({
day: dayStart, monitorId: row.monitorId,
totalChecks: row.totalChecks, day: dayStart,
upChecks: row.upChecks,
avgLatencyMs: row.avgLatencyMs,
minLatencyMs: row.minLatencyMs,
maxLatencyMs: row.maxLatencyMs,
})
.onConflictDoUpdate({
target: [monitorDailyStats.monitorId, monitorDailyStats.day],
set: {
totalChecks: row.totalChecks, totalChecks: row.totalChecks,
upChecks: row.upChecks, upChecks: row.upChecks,
avgLatencyMs: row.avgLatencyMs, avgLatencyMs: row.avgLatencyMs,
minLatencyMs: row.minLatencyMs, minLatencyMs: row.minLatencyMs,
maxLatencyMs: row.maxLatencyMs, maxLatencyMs: row.maxLatencyMs,
}, })
})); .onConflictDoUpdate({
await db.batch(statements as [typeof statements[number], ...typeof statements]); 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 }; return { day: dayStart.toISOString().slice(0, 10), monitors: rows.length };
+38 -45
View File
@@ -1,82 +1,76 @@
import { applyD1Migrations, env, SELF, type D1Migration } from "cloudflare:test"; import { applyD1Migrations, env, SELF, type D1Migration } from 'cloudflare:test';
import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { hashPassword } from "../src/worker/lib/password"; import { hashPassword } from '../src/worker/lib/password';
const ADMIN_PASSWORD = "correct-horse-battery-staple"; const ADMIN_PASSWORD = 'correct-horse-battery-staple';
async function seedAdmin() { async function seedAdmin() {
await env.DB.batch([ await env.DB.batch([
env.DB.prepare("DELETE FROM login_attempts"), env.DB.prepare('DELETE FROM login_attempts'),
env.DB.prepare("DELETE FROM sessions"), env.DB.prepare('DELETE FROM sessions'),
env.DB.prepare("DELETE FROM admin_credentials"), env.DB.prepare('DELETE FROM admin_credentials'),
]); ]);
const now = Date.now(); const now = Date.now();
await env.DB.prepare( await env.DB.prepare('INSERT INTO admin_credentials (id, password_hash, created_at, updated_at) VALUES (1, ?, ?, ?)')
"INSERT INTO admin_credentials (id, password_hash, created_at, updated_at) VALUES (1, ?, ?, ?)", .bind(await hashPassword(ADMIN_PASSWORD), now, now)
)
.bind(
await hashPassword(ADMIN_PASSWORD),
now,
now,
)
.run(); .run();
} }
function login(password = ADMIN_PASSWORD, ipAddress = "198.51.100.10") { function login(password = ADMIN_PASSWORD, ipAddress = '198.51.100.10') {
return SELF.fetch("https://example.com/api/auth/login", { return SELF.fetch('https://example.com/api/auth/login', {
method: "POST", method: 'POST',
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json',
"CF-Connecting-IP": ipAddress, 'CF-Connecting-IP': ipAddress,
Origin: "https://example.com", Origin: 'https://example.com',
}, },
body: JSON.stringify({ password }), body: JSON.stringify({ password }),
}); });
} }
function cookieFrom(response: Response) { 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 () => { beforeAll(async () => {
const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] }; const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
}); });
beforeEach(seedAdmin); 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 response = await login();
const body = await response.json<{ authenticated: boolean }>(); const body = await response.json<{ authenticated: boolean }>();
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.headers.get("Set-Cookie")).toContain("upwatch_session="); expect(response.headers.get('Set-Cookie')).toContain('upwatch_session=');
expect(response.headers.get("Set-Cookie")).toContain("HttpOnly"); expect(response.headers.get('Set-Cookie')).toContain('HttpOnly');
expect(response.headers.get("Set-Cookie")).toContain("SameSite=Lax"); expect(response.headers.get('Set-Cookie')).toContain('SameSite=Lax');
expect(body).toEqual({ authenticated: true }); 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(); expect(session).not.toBeNull();
}); });
it("rejects an incorrect password without setting a cookie", async () => { it('rejects an incorrect password without setting a cookie', async () => {
const response = await login("incorrect-password"); const response = await login('incorrect-password');
expect(response.status).toBe(401); expect(response.status).toBe(401);
expect(response.headers.get("Set-Cookie")).toBeNull(); expect(response.headers.get('Set-Cookie')).toBeNull();
expect(await response.json()).toEqual({ expect(await response.json()).toEqual({
message: "Password is incorrect", message: 'Password is incorrect',
}); });
}); });
it("returns authentication state from the session endpoint", async () => { it('returns authentication state from the session endpoint', async () => {
const anonymousResponse = await SELF.fetch("https://example.com/api/auth/me"); const anonymousResponse = await SELF.fetch('https://example.com/api/auth/me');
expect(anonymousResponse.status).toBe(200); expect(anonymousResponse.status).toBe(200);
expect(await anonymousResponse.json()).toEqual({ authenticated: false }); expect(await anonymousResponse.json()).toEqual({ authenticated: false });
const loginResponse = await login(); 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) }, headers: { Cookie: cookieFrom(loginResponse) },
}); });
@@ -84,39 +78,38 @@ describe("authentication", () => {
expect(await authenticatedResponse.json()).toEqual({ authenticated: true }); 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 loginResponse = await login();
const cookie = cookieFrom(loginResponse); const cookie = cookieFrom(loginResponse);
const logoutResponse = await SELF.fetch("https://example.com/api/auth/logout", { const logoutResponse = await SELF.fetch('https://example.com/api/auth/logout', {
method: "POST", method: 'POST',
headers: { headers: {
Cookie: cookie, Cookie: cookie,
Origin: "https://example.com", Origin: 'https://example.com',
}, },
}); });
expect(logoutResponse.status).toBe(200); expect(logoutResponse.status).toBe(200);
expect(await logoutResponse.json()).toEqual({ ok: true }); expect(await logoutResponse.json()).toEqual({ ok: true });
const sessionCount = await env.DB.prepare("SELECT COUNT(*) AS count FROM sessions") const sessionCount = await env.DB.prepare('SELECT COUNT(*) AS count FROM sessions').first<{ count: number }>();
.first<{ count: number }>();
expect(sessionCount?.count).toBe(0); 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 }, headers: { Cookie: cookie },
}); });
expect(await sessionResponse.json()).toEqual({ authenticated: false }); 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[] = []; const responses: Response[] = [];
for (let attempt = 0; attempt < 11; attempt += 1) { 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.slice(0, 10).every((response) => response.status === 401)).toBe(true);
expect(responses[10].status).toBe(429); expect(responses[10].status).toBe(429);
expect(await responses[10].json()).toEqual({ expect(await responses[10].json()).toEqual({
message: "Too many login attempts. Try again later", message: 'Too many login attempts. Try again later',
}); });
}); });
}); });
+53 -37
View File
@@ -1,23 +1,23 @@
import { applyD1Migrations, env, type D1Migration } from "cloudflare:test"; import { applyD1Migrations, env, type D1Migration } from 'cloudflare:test';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { runDueChecks } from "../src/worker/checks/run-due-checks"; import { runDueChecks } from '../src/worker/checks/run-due-checks';
async function clearMonitoringTables() { async function clearMonitoringTables() {
await env.DB.batch([ await env.DB.batch([
env.DB.prepare("DELETE FROM checks"), env.DB.prepare('DELETE FROM checks'),
env.DB.prepare("DELETE FROM incidents"), env.DB.prepare('DELETE FROM incidents'),
env.DB.prepare("DELETE FROM monitor_daily_stats"), env.DB.prepare('DELETE FROM monitor_daily_stats'),
env.DB.prepare("DELETE FROM notification_settings"), env.DB.prepare('DELETE FROM notification_settings'),
env.DB.prepare("DELETE FROM monitors"), env.DB.prepare('DELETE FROM monitors'),
]); ]);
} }
async function insertMonitor(overrides: Record<string, unknown> = {}) { async function insertMonitor(overrides: Record<string, unknown> = {}) {
const now = Date.now(); const now = Date.now();
const values = { const values = {
name: "Example", name: 'Example',
url: "https://example.com/health", url: 'https://example.com/health',
method: "GET", method: 'GET',
expected_status: 200, expected_status: 200,
interval_seconds: 300, interval_seconds: 300,
timeout_ms: 10_000, timeout_ms: 10_000,
@@ -27,11 +27,13 @@ async function insertMonitor(overrides: Record<string, unknown> = {}) {
updated_at: now, updated_at: now,
...overrides, ...overrides,
}; };
const result = await env.DB.prepare(` const result = await env.DB.prepare(
`
INSERT INTO monitors INSERT INTO monitors
(name, url, method, expected_status, interval_seconds, timeout_ms, enabled, alerts_enabled, last_ok, last_checked_at, created_at, updated_at) (name, url, method, expected_status, interval_seconds, timeout_ms, enabled, alerts_enabled, last_ok, last_checked_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
`) `,
)
.bind( .bind(
values.name, values.name,
values.url, values.url,
@@ -49,7 +51,7 @@ async function insertMonitor(overrides: Record<string, unknown> = {}) {
return Number(result.meta.last_row_id); return Number(result.meta.last_row_id);
} }
describe("scheduled monitor checks", () => { describe('scheduled monitor checks', () => {
beforeAll(async () => { beforeAll(async () => {
const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] }; const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
@@ -57,83 +59,97 @@ describe("scheduled monitor checks", () => {
beforeEach(clearMonitoringTables); beforeEach(clearMonitoringTables);
afterEach(() => vi.unstubAllGlobals()); 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(); 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); const summary = await runDueChecks(env);
expect(summary).toEqual({ checked: 1, up: 1, down: 0 }); 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) .bind(id)
.first<{ last_ok: number; last_status_code: number; last_latency_ms: number; last_checked_at: number }>(); .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).toMatchObject({ last_ok: 1, last_status_code: 200 });
expect(monitor?.last_latency_ms).toEqual(expect.any(Number)); expect(monitor?.last_latency_ms).toEqual(expect.any(Number));
expect(monitor?.last_checked_at).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) .bind(id)
.first<{ count: number }>(); .first<{ count: number }>();
expect(checkCount?.count).toBe(1); 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(); 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); const summary = await runDueChecks(env);
expect(summary).toEqual({ checked: 1, up: 0, down: 1 }); 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) .bind(id)
.first<{ last_ok: number; last_status_code: number; last_error: string }>(); .first<{ last_ok: number; last_status_code: number; last_error: string }>();
expect(monitor).toEqual({ expect(monitor).toEqual({
last_ok: 0, last_ok: 0,
last_status_code: 500, 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) .bind(id)
.first<{ monitor_id: number; resolved_at: number | null; start_status_code: number; start_error: string }>(); .first<{ monitor_id: number; resolved_at: number | null; start_status_code: number; start_error: string }>();
expect(incident).toEqual({ expect(incident).toEqual({
monitor_id: id, monitor_id: id,
resolved_at: null, resolved_at: null,
start_status_code: 500, 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 }); 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); await runDueChecks(env);
const count = await env.DB.prepare("SELECT COUNT(*) AS count FROM incidents WHERE monitor_id = ?") const count = await env.DB.prepare('SELECT COUNT(*) AS count FROM incidents WHERE monitor_id = ?').bind(id).first<{ count: number }>();
.bind(id)
.first<{ count: number }>();
expect(count?.count).toBe(0); 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 id = await insertMonitor({ last_ok: 0 });
const startedAt = Date.now() - 60_000; 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) .bind(id, startedAt, startedAt, startedAt)
.run(); .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); 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) .bind(id)
.first<{ resolved_at: number | null; duration_ms: number | null }>(); .first<{ resolved_at: number | null; duration_ms: number | null }>();
expect(incident?.resolved_at).toEqual(expect.any(Number)); expect(incident?.resolved_at).toEqual(expect.any(Number));
expect(incident?.duration_ms).toBeGreaterThanOrEqual(60_000); expect(incident?.duration_ms).toBeGreaterThanOrEqual(60_000);
}); });
it("skips disabled and not-yet-due monitors", async () => { it('skips disabled and not-yet-due monitors', async () => {
await insertMonitor({ name: "Disabled", enabled: 0 }); await insertMonitor({ name: 'Disabled', enabled: 0 });
await insertMonitor({ name: "Recent", last_checked_at: Date.now() }); await insertMonitor({ name: 'Recent', last_checked_at: Date.now() });
const fetchMock = vi.fn(async () => new Response(null, { status: 200 })); const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal('fetch', fetchMock);
const summary = await runDueChecks(env); const summary = await runDueChecks(env);
expect(summary).toEqual({ checked: 0, up: 0, down: 0 }); expect(summary).toEqual({ checked: 0, up: 0, down: 0 });
+6 -6
View File
@@ -1,9 +1,9 @@
import { SELF } from "cloudflare:test"; import { SELF } from 'cloudflare:test';
import { describe, expect, it } from "vitest"; import { describe, expect, it } from 'vitest';
describe("uptime monitoring Worker", () => { describe('uptime monitoring Worker', () => {
it("returns a successful D1 health response", async () => { it('returns a successful D1 health response', async () => {
const response = await SELF.fetch("https://example.com/api/health"); const response = await SELF.fetch('https://example.com/api/health');
const body = await response.json<{ const body = await response.json<{
ok: boolean; ok: boolean;
db: { ok: number } | null; db: { ok: number } | null;
@@ -11,7 +11,7 @@ describe("uptime monitoring Worker", () => {
}>(); }>();
expect(response.status).toBe(200); 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.ok).toBe(true);
expect(body.db).toEqual({ ok: 1 }); expect(body.db).toEqual({ ok: 1 });
expect(body.ts).toEqual(expect.any(Number)); expect(body.ts).toEqual(expect.any(Number));
+122 -98
View File
@@ -1,13 +1,13 @@
import { applyD1Migrations, env, SELF, type D1Migration } from "cloudflare:test"; import { applyD1Migrations, env, SELF, type D1Migration } from 'cloudflare:test';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { hashPassword } from "../src/worker/lib/password"; import { hashPassword } from '../src/worker/lib/password';
import { resolveFavicon } from "../src/worker/routes/monitors"; import { resolveFavicon } from '../src/worker/routes/monitors';
const ADMIN_PASSWORD = "correct-horse-battery-staple"; const ADMIN_PASSWORD = 'correct-horse-battery-staple';
const VALID_MONITOR = { const VALID_MONITOR = {
name: "Example", name: 'Example',
url: "https://example.com/health", url: 'https://example.com/health',
method: "GET", method: 'GET',
expectedStatus: 200, expectedStatus: 200,
intervalSeconds: 300, intervalSeconds: 300,
timeoutMs: 10_000, timeoutMs: 10_000,
@@ -15,51 +15,51 @@ const VALID_MONITOR = {
async function seedAdmin() { async function seedAdmin() {
await env.DB.batch([ await env.DB.batch([
env.DB.prepare("DELETE FROM checks"), env.DB.prepare('DELETE FROM checks'),
env.DB.prepare("DELETE FROM incidents"), env.DB.prepare('DELETE FROM incidents'),
env.DB.prepare("DELETE FROM monitor_daily_stats"), env.DB.prepare('DELETE FROM monitor_daily_stats'),
env.DB.prepare("DELETE FROM notification_settings"), env.DB.prepare('DELETE FROM notification_settings'),
env.DB.prepare("DELETE FROM monitors"), env.DB.prepare('DELETE FROM monitors'),
env.DB.prepare("DELETE FROM login_attempts"), env.DB.prepare('DELETE FROM login_attempts'),
env.DB.prepare("DELETE FROM sessions"), env.DB.prepare('DELETE FROM sessions'),
env.DB.prepare("DELETE FROM admin_credentials"), env.DB.prepare('DELETE FROM admin_credentials'),
]); ]);
const now = Date.now(); 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) .bind(await hashPassword(ADMIN_PASSWORD), now, now)
.run(); .run();
} }
async function authenticatedCookie() { async function authenticatedCookie() {
const response = await SELF.fetch("https://example.com/api/auth/login", { const response = await SELF.fetch('https://example.com/api/auth/login', {
method: "POST", method: 'POST',
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json',
"CF-Connecting-IP": "198.51.100.20", 'CF-Connecting-IP': '198.51.100.20',
Origin: "https://example.com", Origin: 'https://example.com',
}, },
body: JSON.stringify({ password: ADMIN_PASSWORD }), 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}`, { return SELF.fetch(`https://example.com${path}`, {
method, method,
headers: { headers: {
...(cookie ? { Cookie: cookie } : {}), ...(cookie ? { Cookie: cookie } : {}),
...(method !== "GET" ? { Origin: "https://example.com" } : {}), ...(method !== 'GET' ? { Origin: 'https://example.com' } : {}),
...(body === undefined ? {} : { "Content-Type": "application/json" }), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
}, },
body: body === undefined ? undefined : JSON.stringify(body), body: body === undefined ? undefined : JSON.stringify(body),
}); });
} }
async function createMonitor(cookie: string, overrides: Record<string, unknown> = {}) { async function createMonitor(cookie: string, overrides: Record<string, unknown> = {}) {
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 () => { beforeAll(async () => {
const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] }; const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
@@ -67,153 +67,177 @@ describe("monitor API", () => {
beforeEach(seedAdmin); beforeEach(seedAdmin);
afterEach(() => vi.unstubAllGlobals()); 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 = [ const requests = [
apiFetch("/api/monitors"), apiFetch('/api/monitors'),
apiFetch("/api/monitors/1/favicon"), apiFetch('/api/monitors/1/favicon'),
apiFetch("/api/monitors", "POST", "", VALID_MONITOR), apiFetch('/api/monitors', 'POST', '', VALID_MONITOR),
apiFetch("/api/monitors/1", "PATCH", "", { name: "Changed" }), apiFetch('/api/monitors/1', 'PATCH', '', { name: 'Changed' }),
apiFetch("/api/monitors/1", "DELETE"), apiFetch('/api/monitors/1', 'DELETE'),
apiFetch("/api/monitors/1/check", "POST"), apiFetch('/api/monitors/1/check', 'POST'),
]; ];
const responses = await Promise.all(requests); const responses = await Promise.all(requests);
for (const response of responses) { for (const response of responses) {
expect(response.status).toBe(401); 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 cookie = await authenticatedCookie();
const created = await (await createMonitor(cookie, { url: "https://favicon-route.example.test/health" })) const created = await (
.json<{ monitor: { id: number } }>(); await createMonitor(cookie, { url: 'https://favicon-route.example.test/health' })
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { ).json<{ monitor: { id: number } }>();
const url = new URL(input.toString()); vi.stubGlobal(
expect(url.href).toBe("https://favicon-route.example.test/favicon.ico"); 'fetch',
return new Response(new Uint8Array([0, 0, 1, 0]), { vi.fn(async (input: RequestInfo | URL) => {
headers: { "Content-Type": "image/x-icon" }, 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.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("image/x-icon"); 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('Cache-Control')).toBe('public, max-age=86400');
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff"); expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff');
expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array([0, 0, 1, 0])); 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 fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(input.toString()); const url = new URL(input.toString());
if (url.pathname === "/favicon.ico") return new Response(null, { status: 404 }); if (url.pathname === '/favicon.ico') return new Response(null, { status: 404 });
if (url.pathname === "/") { if (url.pathname === '/') {
return new Response('<html><head><link rel="apple-touch-icon" href="/assets/icon.png"></head></html>', { return new Response('<html><head><link rel="apple-touch-icon" href="/assets/icon.png"></head></html>', {
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]), { 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"); const favicon = await resolveFavicon('https://favicon-head.example.test/status');
expect(favicon?.contentType).toBe("image/png"); expect(favicon?.contentType).toBe('image/png');
expect(new Uint8Array(favicon?.body ?? new ArrayBuffer(0))).toEqual(new Uint8Array([137, 80, 78, 71])); expect(new Uint8Array(favicon?.body ?? new ArrayBuffer(0))).toEqual(new Uint8Array([137, 80, 78, 71]));
expect(fetchMock).toHaveBeenCalledTimes(3); 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(); 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://127.0.0.1/admin')).resolves.toBeNull();
await expect(resolveFavicon("http://[::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://10.0.0.1/admin')).resolves.toBeNull();
expect(fetchMock).not.toHaveBeenCalled(); 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 cookie = await authenticatedCookie();
const response = await createMonitor(cookie); const response = await createMonitor(cookie);
const created = await response.json<{ monitor: { id: number; name: string; enabled: boolean; alertsEnabled: boolean } }>(); const created = await response.json<{ monitor: { id: number; name: string; enabled: boolean; alertsEnabled: boolean } }>();
expect(response.status).toBe(200); 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 }> }>(); const list = await listResponse.json<{ monitors: Array<{ id: number; url: string }> }>();
expect(list.monitors).toHaveLength(1); 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([ it.each([
[{ url: "file:///etc/passwd" }, "Enter a valid http or https URL"], [{ url: 'file:///etc/passwd' }, 'Enter a valid http or https URL'],
[{ intervalSeconds: 60 }, "intervalSeconds must be an integer between 300 and 86400"], [{ intervalSeconds: 60 }, 'intervalSeconds must be an integer between 300 and 86400'],
[{ expectedStatus: 99 }, "expectedStatus must be an integer between 100 and 599"], [{ expectedStatus: 99 }, 'expectedStatus must be an integer between 100 and 599'],
] as const)("rejects invalid monitor input %o", async (overrides, message) => { ] as const)('rejects invalid monitor input %o', async (overrides, message) => {
const response = await createMonitor(await authenticatedCookie(), overrides); const response = await createMonitor(await authenticatedCookie(), overrides);
expect(response.status).toBe(400); expect(response.status).toBe(400);
expect(await response.json()).toEqual({ message }); 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 cookie = await authenticatedCookie();
const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>(); const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>();
const response = await apiFetch(`/api/monitors/${created.monitor.id}`, "PATCH", cookie, { const response = await apiFetch(`/api/monitors/${created.monitor.id}`, 'PATCH', cookie, {
name: "Renamed endpoint", name: 'Renamed endpoint',
enabled: false, enabled: false,
}); });
const body = await response.json<{ monitor: { name: string; url: string; enabled: boolean } }>(); const body = await response.json<{ monitor: { name: string; url: string; enabled: boolean } }>();
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(body.monitor).toMatchObject({ expect(body.monitor).toMatchObject({
name: "Renamed endpoint", name: 'Renamed endpoint',
url: "https://example.com/health", url: 'https://example.com/health',
enabled: false, 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 cookie = await authenticatedCookie();
const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>(); const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>();
const id = created.monitor.id; const id = created.monitor.id;
const now = Date.now(); const now = Date.now();
await env.DB.batch([ 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 (?, 1, 200, 100, ?)').bind(
env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 0, 500, 300, ?)").bind(id, now - 1000), id,
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), 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([ const [detail, checksResponse, incidentsResponse, statsResponse] = await Promise.all([
apiFetch(`/api/monitors/${id}`, "GET", cookie), apiFetch(`/api/monitors/${id}`, 'GET', cookie),
apiFetch(`/api/monitors/${id}/checks`, "GET", cookie), apiFetch(`/api/monitors/${id}/checks`, 'GET', cookie),
apiFetch(`/api/monitors/${id}/incidents`, "GET", cookie), apiFetch(`/api/monitors/${id}/incidents`, 'GET', cookie),
apiFetch(`/api/monitors/${id}/stats`, "GET", cookie), apiFetch(`/api/monitors/${id}/stats`, 'GET', cookie),
]); ]);
expect((await detail.json<{ monitor: { id: number } }>()).monitor.id).toBe(id); expect((await detail.json<{ monitor: { id: number } }>()).monitor.id).toBe(id);
expect((await checksResponse.json<{ checks: unknown[] }>()).checks).toHaveLength(2); expect((await checksResponse.json<{ checks: unknown[] }>()).checks).toHaveLength(2);
expect((await incidentsResponse.json<{ incidents: unknown[] }>()).incidents).toHaveLength(1); 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 } } }>(); const stats = await statsResponse.json<{
expect(stats.windows["24h"]).toEqual({ uptimePct: 50, totalChecks: 2, upChecks: 1, avgLatencyMs: 200, incidentCount: 1 }); 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 cookie = await authenticatedCookie();
const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>(); const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>();
const id = created.monitor.id; const id = created.monitor.id;
const now = new Date(); const now = new Date();
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
await env.DB.batch([ 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(
env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 200, ?)").bind(id, today + 1000), '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)',
env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 200, ?)").bind(id, today + 2000), ).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 response = await apiFetch(`/api/monitors/${id}/stats`, 'GET', cookie);
const stats = await response.json<{ windows: { "30d": { uptimePct: number; totalChecks: number; upChecks: number; avgLatencyMs: number } } }>(); const stats = await response.json<{
expect(stats.windows["30d"]).toMatchObject({ windows: { '30d': { uptimePct: number; totalChecks: number; upChecks: number; avgLatencyMs: number } };
}>();
expect(stats.windows['30d']).toMatchObject({
uptimePct: 80, uptimePct: 80,
totalChecks: 10, totalChecks: 10,
upChecks: 8, 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 cookie = await authenticatedCookie();
const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>(); 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()) .bind(created.monitor.id, Date.now())
.run(); .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(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true }); expect(await response.json()).toEqual({ ok: true });
const monitorCount = await env.DB.prepare("SELECT COUNT(*) AS count FROM monitors").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 }>(); const checkCount = await env.DB.prepare('SELECT COUNT(*) AS count FROM checks').first<{ count: number }>();
expect(monitorCount?.count).toBe(0); expect(monitorCount?.count).toBe(0);
expect(checkCount?.count).toBe(0); expect(checkCount?.count).toBe(0);
}); });
+32 -17
View File
@@ -1,40 +1,55 @@
import { applyD1Migrations, env, type D1Migration } from "cloudflare:test"; import { applyD1Migrations, env, type D1Migration } from 'cloudflare:test';
import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { runDailyRollup } from "../src/worker/scheduled/rollup"; import { runDailyRollup } from '../src/worker/scheduled/rollup';
describe("daily monitor rollups", () => { describe('daily monitor rollups', () => {
beforeAll(async () => { beforeAll(async () => {
const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] }; const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
}); });
beforeEach(async () => { beforeEach(async () => {
await env.DB.batch([ await env.DB.batch([
env.DB.prepare("DELETE FROM monitor_daily_stats"), env.DB.prepare('DELETE FROM monitor_daily_stats'),
env.DB.prepare("DELETE FROM checks"), env.DB.prepare('DELETE FROM checks'),
env.DB.prepare("DELETE FROM incidents"), env.DB.prepare('DELETE FROM incidents'),
env.DB.prepare("DELETE FROM monitors"), env.DB.prepare('DELETE FROM monitors'),
]); ]);
}); });
it("aggregates the previous UTC day and safely upserts on rerun", async () => { it('aggregates the previous UTC day and safely upserts on rerun', async () => {
const now = new Date("2026-08-28T00:05:00.000Z"); const now = new Date('2026-08-28T00:05:00.000Z');
const createdAt = now.getTime(); 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) .bind(createdAt, createdAt)
.run(); .run();
const id = Number(insert.meta.last_row_id); const id = Number(insert.meta.last_row_id);
await env.DB.batch([ 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, 100, ?)').bind(
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")), id,
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")), 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); 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).toHaveLength(1);
expect(rows.results[0]).toEqual({ expect(rows.results[0]).toEqual({
day: Date.parse("2026-08-27T00:00:00Z"), day: Date.parse('2026-08-27T00:00:00Z'),
total_checks: 3, total_checks: 3,
up_checks: 2, up_checks: 2,
avg_latency_ms: 200, avg_latency_ms: 200,
+56 -46
View File
@@ -1,15 +1,15 @@
import { applyD1Migrations, env, SELF, type D1Migration } from "cloudflare:test"; import { applyD1Migrations, env, SELF, type D1Migration } from 'cloudflare:test';
import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
type PublicStatusResponse = { type PublicStatusResponse = {
overall: "operational" | "degraded" | "down"; overall: 'operational' | 'degraded' | 'down';
updatedAt: number; updatedAt: number;
services: Array<{ services: Array<{
id: number; id: number;
name: string; name: string;
status: "up" | "down" | "unknown"; status: 'up' | 'down' | 'unknown';
lastCheckedAt: string | null; lastCheckedAt: string | null;
uptime90d: number | null; uptime90d: number | null;
history: Array<{ day: number; uptimePct: number | null }>; history: Array<{ day: number; uptimePct: number | null }>;
@@ -18,14 +18,14 @@ type PublicStatusResponse = {
async function resetDatabase() { async function resetDatabase() {
await env.DB.batch([ await env.DB.batch([
env.DB.prepare("DELETE FROM checks"), env.DB.prepare('DELETE FROM checks'),
env.DB.prepare("DELETE FROM incidents"), env.DB.prepare('DELETE FROM incidents'),
env.DB.prepare("DELETE FROM monitor_daily_stats"), env.DB.prepare('DELETE FROM monitor_daily_stats'),
env.DB.prepare("DELETE FROM notification_settings"), env.DB.prepare('DELETE FROM notification_settings'),
env.DB.prepare("DELETE FROM monitors"), env.DB.prepare('DELETE FROM monitors'),
env.DB.prepare("DELETE FROM login_attempts"), env.DB.prepare('DELETE FROM login_attempts'),
env.DB.prepare("DELETE FROM sessions"), env.DB.prepare('DELETE FROM sessions'),
env.DB.prepare("DELETE FROM admin_credentials"), env.DB.prepare('DELETE FROM admin_credentials'),
]); ]);
} }
@@ -39,13 +39,15 @@ async function insertMonitor(input: {
lastError?: string | null; lastError?: string | null;
}) { }) {
const now = Date.now(); const now = Date.now();
const result = await env.DB.prepare(` const result = await env.DB.prepare(
`
INSERT INTO monitors ( INSERT INTO monitors (
name, url, method, expected_status, interval_seconds, timeout_ms, name, url, method, expected_status, interval_seconds, timeout_ms,
enabled, alerts_enabled, last_ok, last_status_code, last_latency_ms, enabled, alerts_enabled, last_ok, last_status_code, last_latency_ms,
last_error, last_checked_at, created_at, updated_at last_error, last_checked_at, created_at, updated_at
) VALUES (?, ?, 'GET', 200, 300, 10000, ?, 1, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, 'GET', 200, 300, 10000, ?, 1, ?, ?, ?, ?, ?, ?, ?)
`) `,
)
.bind( .bind(
input.name, input.name,
input.url ?? `https://${input.name.toLowerCase()}.example.com/health`, input.url ?? `https://${input.name.toLowerCase()}.example.com/health`,
@@ -62,76 +64,84 @@ async function insertMonitor(input: {
return Number(result.meta.last_row_id); return Number(result.meta.last_row_id);
} }
function statusFetch(path = "/api/status") { function statusFetch(path = '/api/status') {
return SELF.fetch(`https://example.com${path}`); return SELF.fetch(`https://example.com${path}`);
} }
describe("public status API", () => { describe('public status API', () => {
beforeAll(async () => { beforeAll(async () => {
const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] }; const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
}); });
beforeEach(resetDatabase); 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({ await insertMonitor({
name: "Public API", name: 'Public API',
lastOk: true, lastOk: true,
lastStatusCode: 200, lastStatusCode: 200,
lastLatencyMs: 42, lastLatencyMs: 42,
lastError: "sensitive diagnostic", lastError: 'sensitive diagnostic',
}); });
const response = await statusFetch(); const response = await statusFetch();
const body = await response.json<PublicStatusResponse>(); const body = await response.json<PublicStatusResponse>();
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(body.overall).toBe("operational"); expect(body.overall).toBe('operational');
expect(body.updatedAt).toEqual(expect.any(Number)); expect(body.updatedAt).toEqual(expect.any(Number));
expect(body.services).toHaveLength(1); expect(body.services).toHaveLength(1);
expect(body.services[0]).toMatchObject({ expect(body.services[0]).toMatchObject({
name: "Public API", name: 'Public API',
status: "up", status: 'up',
uptime90d: null, uptime90d: null,
history: [], 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); expect(body.services[0]).not.toHaveProperty(privateField);
} }
}); });
it("excludes disabled monitors and reports degraded health for a partial outage", async () => { it('excludes disabled monitors and reports degraded health for a partial outage', async () => {
await insertMonitor({ name: "Healthy", lastOk: true }); await insertMonitor({ name: 'Healthy', lastOk: true });
await insertMonitor({ name: "Unavailable", lastOk: false }); await insertMonitor({ name: 'Unavailable', lastOk: false });
await insertMonitor({ name: "Disabled secret", enabled: false, lastOk: false }); await insertMonitor({ name: 'Disabled secret', enabled: false, lastOk: false });
await insertMonitor({ name: "New service", lastOk: null }); await insertMonitor({ name: 'New service', lastOk: null });
const response = await statusFetch(); const response = await statusFetch();
const body = await response.json<PublicStatusResponse>(); const body = await response.json<PublicStatusResponse>();
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(body.overall).toBe("degraded"); expect(body.overall).toBe('degraded');
expect(body.services.map((service) => service.name)).toEqual(["Healthy", "Unavailable", "New service"]); 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.services.map((service) => service.status)).toEqual(['up', 'down', 'unknown']);
}); });
it("reports a total outage when every checked service is down", async () => { it('reports a total outage when every checked service is down', async () => {
await insertMonitor({ name: "Website", lastOk: false }); await insertMonitor({ name: 'Website', lastOk: false });
await insertMonitor({ name: "API", lastOk: false }); await insertMonitor({ name: 'API', lastOk: false });
await insertMonitor({ name: "Not checked", lastOk: null }); await insertMonitor({ name: 'Not checked', lastOk: null });
const body = await (await statusFetch()).json<PublicStatusResponse>(); const body = await (await statusFetch()).json<PublicStatusResponse>();
expect(body.overall).toBe("down"); expect(body.overall).toBe('down');
}); });
it("combines historical daily rollups and today's checks into 90-day uptime", async () => { 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 now = new Date();
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
await env.DB.batch([ 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(
env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 90, ?)").bind(id, today + 1_000), '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)',
env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 110, ?)").bind(id, today + 2_000), ).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<PublicStatusResponse>(); const body = await (await statusFetch()).json<PublicStatusResponse>();
@@ -144,16 +154,16 @@ describe("public status API", () => {
]); ]);
}); });
it("keeps the administrative monitor collection protected", async () => { it('keeps the administrative monitor collection protected', async () => {
const response = await SELF.fetch("https://example.com/api/monitors"); const response = await SELF.fetch('https://example.com/api/monitors');
expect(response.status).toBe(401); 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 () => { it('does not expose a favicon for a disabled service', async () => {
const id = await insertMonitor({ name: "Private", enabled: false }); const id = await insertMonitor({ name: 'Private', enabled: false });
const response = await statusFetch(`/api/status/${id}/favicon`); const response = await statusFetch(`/api/status/${id}/favicon`);
expect(response.status).toBe(404); expect(response.status).toBe(404);
expect(await response.json()).toEqual({ message: "Service not found" }); expect(await response.json()).toEqual({ message: 'Service not found' });
}); });
}); });
+52 -22
View File
@@ -1,44 +1,74 @@
import { applyD1Migrations, env, type D1Migration } from "cloudflare:test"; import { applyD1Migrations, env, type D1Migration } from 'cloudflare:test';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { sendIncidentAlert } from "../src/worker/notifications/webhook"; import { sendIncidentAlert } from '../src/worker/notifications/webhook';
import type { Monitor } from "../src/worker/checks/run-check"; import type { Monitor } from '../src/worker/checks/run-check';
describe("incident webhooks", () => { describe('incident webhooks', () => {
beforeAll(async () => { beforeAll(async () => {
const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] }; const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
}); });
beforeEach(async () => { beforeEach(async () => {
await env.DB.prepare("DELETE FROM notification_settings").run(); await env.DB.prepare('DELETE FROM notification_settings').run();
const now = Date.now(); 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()); afterEach(() => vi.unstubAllGlobals());
const monitor = { const monitor = {
id: 7, name: "API", url: "https://api.example.com", method: "GET", expectedStatus: 200, id: 7,
intervalSeconds: 300, timeoutMs: 10000, enabled: true, alertsEnabled: true, lastOk: true, name: 'API',
lastStatusCode: 200, lastLatencyMs: 30, lastError: null, lastCheckedAt: null, url: 'https://api.example.com',
createdAt: new Date(), updatedAt: new Date(), 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; } 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 })); const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal('fetch', fetchMock);
const at = new Date("2026-08-28T03:25:00Z"); 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); 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]; const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(String(init?.body))).toEqual({ expect(JSON.parse(String(init?.body))).toEqual({
event: "down", event: 'down',
monitor: { id: 7, name: "API", url: "https://api.example.com" }, monitor: { id: 7, name: 'API', url: 'https://api.example.com' },
statusCode: 500, statusCode: 500,
error: "Down", error: 'Down',
at: "2026-08-28T03:25:00.000Z", at: '2026-08-28T03:25:00.000Z',
}); });
}); });
it("swallows webhook network failures", async () => { it('swallows webhook network failures', async () => {
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network unavailable"); })); vi.stubGlobal(
await expect(sendIncidentAlert(env, { monitor, kind: "resolved", result: { ok: true, statusCode: 200, latencyMs: 20, error: null }, at: new Date() })).resolves.toBe(false); '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);
}); });
}); });
+1 -6
View File
@@ -15,10 +15,5 @@
"types": ["./worker-configuration.d.ts", "vite/client", "node"] "types": ["./worker-configuration.d.ts", "vite/client", "node"]
}, },
"exclude": ["dist", "node_modules", "test"], "exclude": ["dist", "node_modules", "test"],
"include": [ "include": ["worker-configuration.d.ts", "src/**/*.ts", "src/**/*.tsx", "vite.config.ts"]
"worker-configuration.d.ts",
"src/**/*.ts",
"src/**/*.tsx",
"vite.config.ts"
]
} }
+4 -4
View File
@@ -1,7 +1,7 @@
import { cloudflare } from "@cloudflare/vite-plugin"; import { cloudflare } from '@cloudflare/vite-plugin';
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from '@tailwindcss/vite';
import react from "@vitejs/plugin-react"; import react from '@vitejs/plugin-react';
import { defineConfig } from "vite"; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss(), cloudflare()], plugins: [react(), tailwindcss(), cloudflare()],
+4 -4
View File
@@ -1,12 +1,12 @@
import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-plugin"; import { cloudflareTest, readD1Migrations } from '@cloudflare/vitest-plugin';
import { defineConfig } from "vitest/config"; import { defineConfig } from 'vitest/config';
const migrations = await readD1Migrations("./migrations"); const migrations = await readD1Migrations('./migrations');
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
cloudflareTest({ cloudflareTest({
wrangler: { configPath: "./wrangler.jsonc" }, wrangler: { configPath: './wrangler.jsonc' },
miniflare: { miniflare: {
bindings: { TEST_MIGRATIONS: migrations }, bindings: { TEST_MIGRATIONS: migrations },
}, },
+7 -7
View File
@@ -7,24 +7,24 @@
"assets": { "assets": {
"directory": "./dist/client", "directory": "./dist/client",
"not_found_handling": "single-page-application", "not_found_handling": "single-page-application",
"run_worker_first": ["/api/*"] "run_worker_first": ["/api/*"],
}, },
"triggers": { "triggers": {
"crons": ["*/5 * * * *", "5 0 * * *"] "crons": ["*/5 * * * *", "5 0 * * *"],
}, },
"d1_databases": [ "d1_databases": [
{ {
"binding": "DB", "binding": "DB",
"database_name": "uptime", "database_name": "uptime",
"database_id": "960b26fd-a11b-4899-826b-d3513e499d3a", "database_id": "960b26fd-a11b-4899-826b-d3513e499d3a",
"migrations_dir": "migrations" "migrations_dir": "migrations",
} },
], ],
"observability": { "observability": {
"enabled": true, "enabled": true,
"logs": { "logs": {
"head_sampling_rate": 1 "head_sampling_rate": 1,
} },
}, },
"upload_source_maps": true "upload_source_maps": true,
} }