mirror of
https://github.com/Nezumi-2711/uptime-monitoring.git
synced 2026-09-22 05:41:59 +00:00
feat: setup oxlint and prettier for the project
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
bunx lint-staged
|
||||||
@@ -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/**"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
dist
|
||||||
|
.wrangler
|
||||||
|
node_modules
|
||||||
|
bun.lock
|
||||||
|
worker-configuration.d.ts
|
||||||
|
migrations
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["esbenp.prettier-vscode", "oxc.oxc-vscode", "EditorConfig.EditorConfig"]
|
||||||
|
}
|
||||||
Vendored
+20
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
@@ -284,7 +288,7 @@ 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 |
|
||||||
@@ -299,39 +303,45 @@ Code blocks use **system mono** (`ui-monospace`, with Menlo / Monaco / Consolas
|
|||||||
| `{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**: 64–96px on marketing surfaces.
|
- **Section padding**: 64–96px 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 64–96px section padding without atmospheric gradients filling the space — the white canvas is the design. The composited product UI mockups break up sections without requiring decoration.
|
The brand uses generous 64–96px section padding without atmospheric gradients filling the space — the white canvas is the design. The composited product UI mockups break up sections without requiring decoration.
|
||||||
|
|
||||||
## Elevation & Depth
|
## 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
|
||||||
@@ -339,7 +349,7 @@ The brand's depth is **product UI mockups** rather than gradients. Stacked dashb
|
|||||||
### 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 |
|
||||||
@@ -348,6 +358,7 @@ The brand's depth is **product UI mockups** rather than gradients. Stacked dashb
|
|||||||
| `{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 (~24–32px) 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 (~24–32px) 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 4–5 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 4–5 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.
|
||||||
@@ -434,22 +462,25 @@ 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 | 1024–1440px | Default content max-width; pricing 4-up |
|
| Desktop | 1024–1440px | Default content max-width; pricing 4-up |
|
||||||
| Tablet | 768–1023px | Pricing 2-up; mockups simplify to single panel |
|
| Tablet | 768–1023px | 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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
+19
-1
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>) {
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<input
|
||||||
|
id="webhook-url"
|
||||||
|
type="url"
|
||||||
|
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>}
|
{updateMutation.isSuccess && <p className="settings-success">Notification settings saved.</p>}
|
||||||
{testMutation.isSuccess && <p className="settings-success">Test webhook delivered successfully.</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>}
|
{(updateMutation.isError || testMutation.isError) && (
|
||||||
</form>;
|
<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>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>;
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 = () =>
|
||||||
|
|||||||
@@ -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 = () =>
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||||
|
|||||||
@@ -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",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
item.transition === null
|
||||||
|
? []
|
||||||
|
: [
|
||||||
sendIncidentAlert(env, {
|
sendIncidentAlert(env, {
|
||||||
monitor: item.monitor,
|
monitor: item.monitor,
|
||||||
kind: item.transition,
|
kind: item.transition,
|
||||||
result: item.result,
|
result: item.result,
|
||||||
at: item.checkedAt,
|
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);
|
||||||
|
|||||||
@@ -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
@@ -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),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|||||||
+23
-21
@@ -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({
|
||||||
|
message: 'daily rollup completed',
|
||||||
cron: controller.cron,
|
cron: controller.cron,
|
||||||
scheduledTime: controller.scheduledTime,
|
scheduledTime: controller.scheduledTime,
|
||||||
...result,
|
...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,
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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,37 +34,32 @@ 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({
|
||||||
|
message: 'test webhook failed',
|
||||||
error: error instanceof Error ? error.message : String(error),
|
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,
|
||||||
@@ -75,18 +70,22 @@ export async function sendIncidentAlert(
|
|||||||
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({
|
||||||
|
message: 'incident webhook returned an error',
|
||||||
monitorId: alert.monitor.id,
|
monitorId: alert.monitor.id,
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return ok;
|
return ok;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(JSON.stringify({
|
console.error(
|
||||||
message: "incident webhook failed",
|
JSON.stringify({
|
||||||
|
message: 'incident webhook failed',
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
monitorId: alert.monitor.id,
|
monitorId: alert.monitor.id,
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-50
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+133
-140
@@ -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,29 +383,33 @@ 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(
|
||||||
|
windows.map(async (window) => {
|
||||||
const [aggregate] = window.raw
|
const [aggregate] = window.raw
|
||||||
? await db.select({
|
? await db
|
||||||
|
.select({
|
||||||
totalChecks: sql<number>`count(*)`,
|
totalChecks: sql<number>`count(*)`,
|
||||||
upChecks: sql<number>`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
|
upChecks: sql<number>`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
|
||||||
avgLatencyMs: sql<number | null>`round(avg(${checks.latencyMs}))`,
|
avgLatencyMs: sql<number | null>`round(avg(${checks.latencyMs}))`,
|
||||||
}).from(checks).where(and(eq(checks.monitorId, id), gte(checks.checkedAt, new Date(window.start))))
|
})
|
||||||
|
.from(checks)
|
||||||
|
.where(and(eq(checks.monitorId, id), gte(checks.checkedAt, new Date(window.start))))
|
||||||
: await db.select({
|
: await db.select({
|
||||||
totalChecks: sql<number>`coalesce(sum(total_checks), 0)`,
|
totalChecks: sql<number>`coalesce(sum(total_checks), 0)`,
|
||||||
upChecks: sql<number>`coalesce(sum(up_checks), 0)`,
|
upChecks: sql<number>`coalesce(sum(up_checks), 0)`,
|
||||||
@@ -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
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(incidents)
|
.from(incidents)
|
||||||
.where(and(
|
.where(
|
||||||
|
and(
|
||||||
eq(incidents.monitorId, id),
|
eq(incidents.monitorId, id),
|
||||||
gte(incidents.startedAt, new Date(window.start)),
|
gte(incidents.startedAt, new Date(window.start)),
|
||||||
or(isNull(incidents.resolvedAt), gte(incidents.resolvedAt, new Date(window.start))),
|
or(isNull(incidents.resolvedAt), gte(incidents.resolvedAt, new Date(window.start))),
|
||||||
));
|
),
|
||||||
|
);
|
||||||
return [window.key, asStatsWindow(aggregate, incidentAggregate.count)] as const;
|
return [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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+34
-33
@@ -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
|
||||||
|
.select({
|
||||||
monitorId: monitorDailyStats.monitorId,
|
monitorId: monitorDailyStats.monitorId,
|
||||||
day: monitorDailyStats.day,
|
day: monitorDailyStats.day,
|
||||||
totalChecks: monitorDailyStats.totalChecks,
|
totalChecks: monitorDailyStats.totalChecks,
|
||||||
upChecks: monitorDailyStats.upChecks,
|
upChecks: monitorDailyStats.upChecks,
|
||||||
})
|
})
|
||||||
.from(monitorDailyStats)
|
.from(monitorDailyStats)
|
||||||
.where(and(
|
.where(
|
||||||
|
and(
|
||||||
inArray(monitorDailyStats.monitorId, monitorIds),
|
inArray(monitorDailyStats.monitorId, monitorIds),
|
||||||
gte(monitorDailyStats.day, new Date(cutoff)),
|
gte(monitorDailyStats.day, new Date(cutoff)),
|
||||||
lt(monitorDailyStats.day, new Date(today)),
|
lt(monitorDailyStats.day, new Date(today)),
|
||||||
))
|
),
|
||||||
|
)
|
||||||
.orderBy(monitorDailyStats.day),
|
.orderBy(monitorDailyStats.day),
|
||||||
db.select({
|
db
|
||||||
|
.select({
|
||||||
monitorId: checks.monitorId,
|
monitorId: checks.monitorId,
|
||||||
day: sql<Date>`cast(${today} as integer)`,
|
day: sql<Date>`cast(${today} as integer)`,
|
||||||
totalChecks: sql<number>`count(*)`,
|
totalChecks: sql<number>`count(*)`,
|
||||||
upChecks: sql<number>`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
|
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) {
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +27,8 @@ 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) =>
|
||||||
|
db
|
||||||
.insert(monitorDailyStats)
|
.insert(monitorDailyStats)
|
||||||
.values({
|
.values({
|
||||||
monitorId: row.monitorId,
|
monitorId: row.monitorId,
|
||||||
@@ -50,8 +48,9 @@ export async function runDailyRollup(
|
|||||||
minLatencyMs: row.minLatencyMs,
|
minLatencyMs: row.minLatencyMs,
|
||||||
maxLatencyMs: row.maxLatencyMs,
|
maxLatencyMs: row.maxLatencyMs,
|
||||||
},
|
},
|
||||||
}));
|
}),
|
||||||
await db.batch(statements as [typeof statements[number], ...typeof statements]);
|
);
|
||||||
|
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
@@ -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
@@ -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
@@ -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));
|
||||||
|
|||||||
+119
-95
@@ -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 } }>();
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
const url = new URL(input.toString());
|
const url = new URL(input.toString());
|
||||||
expect(url.href).toBe("https://favicon-route.example.test/favicon.ico");
|
expect(url.href).toBe('https://favicon-route.example.test/favicon.ico');
|
||||||
return new Response(new Uint8Array([0, 0, 1, 0]), {
|
return new Response(new Uint8Array([0, 0, 1, 0]), {
|
||||||
headers: { "Content-Type": "image/x-icon" },
|
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user