diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8780994..b0a35ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,7 +3,7 @@ ## Ground rules - One binary, one database file, zero cgo. Dependencies need justification. -- Honest history is the invariant: missing telemetry is `null`, never +- Exact history is the invariant: missing telemetry is `null`, never interpolated. Any change that fabricates uptime will be rejected. - Headless-first: every mutation must work via the REST API. The embedded UI consumes the API; it never bypasses it. diff --git a/README.md b/README.md index cc00529..6a9fed6 100644 --- a/README.md +++ b/README.md @@ -10,17 +10,22 @@ file, no accounts, no SaaS. - **Probe anything HTTP** — per-service interval, timeout, expected statuses, headers, body matching, self-signed TLS opt-in. -- **Honest history** — every probe stored; days without data report `null`, +- **Exact history** — every probe stored; days without data report `null`, never fake green; flap-tolerant day rollups. - **Incident log included** — open, narrate and resolve incidents via API. +- **Status UI in the box** — the binary serves its own status page with + 90-day bars at `/`; no second deployment. - **Deploy-ready** — bearer auth, rate limits, Prometheus metrics, readiness probe, versioned API with an embedded OpenAPI contract. ## Quick start +Generate a config, check it, run it: + ```sh -cp config.example.yaml config.yaml # point it at your endpoints -go run ./cmd/epmon -config config.yaml +epmon init # interactive walkthrough → epmon.yaml +epmon validate --config epmon.yaml # parses + validates, no side effects +epmon run --config epmon.yaml # probe + serve ``` ```sh @@ -42,6 +47,9 @@ $ curl -s localhost:8080/api/v1/status | jq . } ``` +Then open `http://localhost:8080/` — the status page with 90-day bars, +served by the same binary. Details in [Status UI](#status-ui). + With Docker: ```sh @@ -59,36 +67,58 @@ DATE=$(date -u +%FT%TZ) docker compose up --build -d docker exec epmon /epmon version # epmon (commit , built , ...) ``` -Interactive API reference lives at `/docs` once it's running; the raw -contract at `/api/v1/openapi.yaml` (or `.json`). +## Installation -## A full round trip - -Probes run on their own — here's the human side: +Pick one: ```sh -# something breaks: open an incident (needs an API key, see below) -curl -s -X POST localhost:8080/api/v1/incidents \ - -H "Authorization: Bearer $EPMON_API_KEY" \ - -d '{"service_id":"api","title":"Elevated latency","severity":"minor"}' | jq . -# {"id":1,...,"state":"investigating","updates":[]} +# from source (Go 1.25+) +go install github.com/epmon-dev/epmon/cmd/epmon@v0.1.0 -# narrate as you work -curl -s -X POST localhost:8080/api/v1/incidents/1/updates \ - -H "Authorization: Bearer $EPMON_API_KEY" \ - -d '{"text":"Slow query found, index added."}' -o /dev/null -w "%{http_code}\n" -# 201 +# release binaries (linux/amd64+arm64, darwin/arm64, windows/amd64) +# → https://github.com/epmon-dev/epmon/releases (stamped, no build needed) -# fixed: resolve it -curl -s -X PATCH localhost:8080/api/v1/incidents/1 \ - -H "Authorization: Bearer $EPMON_API_KEY" \ - -d '{"state":"resolved"}' | jq .state -# "resolved" +# docker +docker compose up --build -d # see VERSION/COMMIT/DATE above for stamping +``` + +Or `make build` in a checkout — same flags the Dockerfile uses +(`make help` lists every target: `test`, `cross`, `lint`, `check-docs`…). + +## CLI reference -# history for the status page: buckets plus uptime over the window -curl -s "localhost:8080/api/v1/services/api/history?days=90" | jq '{uptime_pct, days}' -# {"uptime_pct": 99.97, "days": 90} ``` +epmon run [--config PATH] probe + serve (default command) +epmon validate [--config PATH] parse + validate only; exit 0/1, no side effects +epmon healthcheck --endpoint URL GET /healthz; exit 0 on 200, 4 otherwise +epmon version print version/commit/date; exit 0 +epmon init [flags] scaffold a config (see below) +``` + +Exit codes: `0` ok, `1` config error, `2` storage error, `3` listen/bind +error, `4` failed `healthcheck`, `64` usage error. + +### `epmon init` + +Two modes, one code path — the output always passes `validate`: + +```sh +# interactive: prompts with [defaults], empty takes them, Ctrl-C aborts +epmon init --output epmon.yaml + +# scripted: same walk driven by flags (repeat --service) +epmon init --non-interactive --output epmon.yaml \ + --server-listen 127.0.0.1:8080 \ + --service 'api=https://api.example.com/healthz;interval=30s;expect=200,301' \ + --service 'web=https://example.com' +``` + +`--service` takes `id=url[;key=value...]` with keys `name`, `interval`, +`timeout`, `expect`, `body_contains`. Expected statuses accept every form +the loader does (`200`, `2xx`, `200,301`). Header values are +interactive-only, and `$NAME` becomes `${NAME}` so secrets stay references, +never literal tokens. Existing files are never overwritten without +`--force`; nothing is written unless the document validates. ## Configuration @@ -100,25 +130,34 @@ always use braces. ```yaml server: - addr: ":8080" + listen: ":8080" # bind address; addr is the legacy alias api_keys: ["${EPMON_API_KEY}"] # writes need Bearer; empty = off (dev only) - rate_limit_rpm: 120 # per client IP; 429 + Retry-After past it + rate_limit_rpm: 120 # per client IP; 429 + Retry-After past it rate_limit_burst: 120 - trust_proxy: false # true only behind a sanitizing proxy - cors_allowed_origins: [] # e.g. ["https://status.example.com"] + trust_proxy: false # true only behind a sanitizing proxy + cors_allowed_origins: [] # e.g. ["https://status.example.com"] max_body_bytes: 1048576 + status_page: {enabled: true} # the UI at /; false restores JSON 404s # tls_cert: "/data/tls/cert.pem" # or terminate TLS at your proxy # tls_key: "/data/tls/key.pem" database: driver: sqlite # adapter name; postgres later without touching callers - dsn: "epmon.db" # adapter connection string (":memory:" = ephemeral) + dsn: "epmon.db" # adapter connection string (":memory:" = ephemeral) retention_days: 90 probes: # defaults; every field overridable per service default_interval: 60s default_timeout: 10s failure_threshold: 1 + concurrency: 64 + max_body_bytes: 1048576 + +history: + timezone: UTC # IANA name; day buckets cut at its midnights + +api: + max_page_size: 100 # incident-list page cap (10..1000) services: - id: website # required, unique; name defaults to id @@ -133,17 +172,116 @@ services: headers: Authorization: "Bearer ${TOKEN}" # insecure_skip_verify: true # only for boxes you own + # enabled: false # skip probing, keep serving history ``` -A probe is **up** when it answers within `timeout` with a status in -`expect_status` (after redirects) and — if set — the body contains -`body_contains`. Anything else stores `up: false` with a short error -(`transport: …`, `status: got 500`, `body: …`). +Key rules, enforced at load (unknown keys are errors, with file:line): + +- **Service identity:** `id` matches `^[a-z0-9][a-z0-9_-]{0,63}$`, unique; + at least one service is required. Renaming an id archives the old row + and starts fresh history — history moves only via explicit migration. +- **Budgets:** service `interval` 5s–24h, `timeout` ≥1s and strictly below + its interval; unset fields inherit the probe defaults; unset + `expect_status` means `[200]`. +- **Up means:** answered within `timeout`, status in `expect_status` + (after redirects), and — if set — body contains `body_contains`. + Anything else stores `up: false` with a short error (`transport: …`, + `status: got 500`, `body: …`). +- **`enabled: false`** stops probing that service; its history, incidents + and catalog entries keep serving (see [Run modes](#run-modes)). Config location, in order: `--config `, `EPMON_CONFIG`, `./epmon.yaml`, `/etc/epmon/epmon.yaml`, then `config.yaml`. -Exit codes: `0` ok, `1` config error, `2` storage error, `3` listen/bind -error, `4` failed `healthcheck`, `64` usage error. + +## Run modes + +**Full (default).** `epmon run --config epmon.yaml` — probes, API, UI. +This is the mode everything else degrades from. + +**Headless API.** The UI is the only optional surface: + +```yaml +server: + listen: ":8080" + status_page: {enabled: false} +database: + driver: sqlite + dsn: "epmon.db" + retention_days: 90 +services: + - id: website + url: https://example.com +``` + +Probes, API, metrics and `/healthz` behave exactly as before; `/` +and friends return the JSON 404 again. Use it behind a reverse proxy +that serves its own frontend, or when the binary is a pure data plane. + +**API + history, no active probing.** Mark services `enabled: false` +(either in the file or by flipping them later): the scheduler skips +them, nothing new is recorded, and the API keeps serving the catalog, +history, incidents and last-known states. Useful for a retired +environment you still want readable, or a warm standby that must not +generate traffic until you flip it back. + +**Ephemeral.** `dsn: ":memory:"` — full behavior, zero disk. Probes run, +API answers, restart wipes everything. Made for CI smoke tests and +`init` output trials: + +```sh +epmon init --non-interactive --output /tmp/try.yaml --db-dsn ":memory:" \ + --service 'demo=https://example.com;interval=15s' +epmon validate --config /tmp/try.yaml && epmon run --config /tmp/try.yaml +``` + +**Validate-only.** `epmon validate` in CI or pre-deploy hooks: parses, +substitutes env, enforces every rule above, touches no network or disk +beyond reading the file. Fails closed on unknown keys and unset +`${VAR}` (all missing vars reported at once, with file:line). + +## Status UI + +The binary serves its own status page — no second deployment: + +| Path | What | +|---|---| +| `/` | redirects to the local core's services | +| `/local` | your services: state, latency, 90-day bars, incident feed | +| `/p/:slug`, `/s/:domain` | multi-tenant demo routes (preview/testing) | +| `/embed` | badge snippet docs; `/embed.js` is the 2KB badge script | + +Bars are exact history rendered honestly: green days held up, red days +didn't, hatched days had no telemetry — never interpolated. Hover any +bar for the date, outcome and check count. Disable the whole surface +with `status_page.enabled: false` (see [Run modes](#run-modes)). + +## A full round trip + +Probes run on their own — here's the human side: + +```sh +# something breaks: open an incident (needs an API key, see below) +curl -s -X POST localhost:8080/api/v1/incidents \ + -H "Authorization: Bearer $EPMON_API_KEY" \ + -d '{"service_id":"api","title":"Elevated latency","severity":"minor"}' | jq . +# {"id":1,...,"state":"investigating","updates":[]} + +# narrate as you work +curl -s -X POST localhost:8080/api/v1/incidents/1/updates \ + -H "Authorization: Bearer $EPMON_API_KEY" \ + -d '{"text":"Slow query found, index added."}' -o /dev/null -w "%{http_code}\n" +# 201 + +# fixed: resolve it +curl -s -X PATCH localhost:8080/api/v1/incidents/1 \ + -H "Authorization: Bearer $EPMON_API_KEY" \ + -d '{"state":"resolved"}' | jq .state +# "resolved" + +# history for the status page: buckets plus uptime over the window +curl -s "localhost:8080/api/v1/services/api/history?days=90" | jq '{uptime_pct, days}' +# {"uptime_pct": 99.97, "days": 90} +``` ## API (`/api/v1`) @@ -155,10 +293,10 @@ Full contract with schemas: `/docs`, or raw at `/api/v1/openapi.yaml`. | GET | `/api/v1/status` | `overall` (`operational`\|`partial_outage`\|`unknown`) + last check per service | | GET | `/api/v1/services` | same per-service states as a list | | GET | `/api/v1/services/{id}/history?days=90&limit=100` | daily buckets (1–365), `uptime_pct`, newest raw checks (1–500) | -| GET | `/api/v1/incidents[?state=][?service=]` | newest first, update threads embedded | +| GET | `/api/v1/incidents?state=&service=&limit=&offset=` | newest first, update threads embedded; `state` rejects typos with 400 | | POST | `/api/v1/incidents` | `{service_id?, title, severity?}` → 201 + `Location` | | GET | `/api/v1/incidents/{id}` | single incident with updates | -| PATCH | `/api/v1/incidents/{id}` | `{title?, severity?, state?}` | +| PATCH | `/api/v1/incidents/{id}` | `{title?, severity?, state?}` (forward-only states) | | POST | `/api/v1/incidents/{id}/updates` | `{text}` → 201 + `Location` | Conventions: single resources are bare objects, collections are @@ -198,6 +336,12 @@ service at one probe/minute over 90 days. `sqlite3 /data/epmon.db ".backup '/backup/epmon-$(date -u +%FT%TZ).db'"` (then `PRAGMA integrity_check` on restores), or mount the volume into your backup job and back up from there. +- **Upgrades:** stop the binary, replace it, start it. Schema migrations + are forward-only and run at boot; downgrading across a migration + boundary refuses to start — read the release notes first. +- **Config changes:** edit the file, then `epmon validate` before + reloading the process. Unknown keys fail closed so typos can't + silently disable a probe. ## Metrics (`/metrics`, Prometheus text format) @@ -217,6 +361,58 @@ won't be renamed. | `epmon_config_reload_total{result="ok"\|"error"}` | counter | Reserved: config reload outcomes (no reload path exists yet). | | `epmon_uptime_seconds`, `epmon_build_info` | gauge | Process age and build identity (`version`/`commit` from release ldflags). | +## Troubleshooting + +**`bind: address already in use` (exit 3).** Something owns the port. +Point `server.listen` at a free one (`127.0.0.1:18082` for local trials) +— the binary exits instead of hanging, by design. + +**`validate` fails on `${VAR}`.** The variable isn't set where you run +it. Export it or use `${VAR:-fallback}`. All missing vars are reported +at once, with file:line — fix them all in one pass. + +**A service never leaves `unknown`.** `up: null` means zero checks have +landed: either the scheduler hasn't ticked yet (first probe fires at +boot, so wait one interval), the service is `enabled: false`, or every +probe is erroring before evaluation (check the process log for the +`transport:` reason). + +**History shows `up: null` for past days.** That's the design, not data +loss: days before the first recorded check report null. Backfilling +would be fabrication. + +**429s from your own monitoring.** You're polling faster than +`rate_limit_rpm` allows. Raise the budget or slow the poller; reads and +the UI share the same bucket. + +**Timezone day boundaries look off.** Buckets cut at `history.timezone` +midnights (default UTC), not the viewer's. Set the IANA name you +operate in. + +## FAQ + +**Can it serve the API without probing?** Yes — set `enabled: false` +on every service ([Run modes](#run-modes)): no traffic generated, +catalog/history/incidents keep serving. There is no separate +API-only binary, by design: one binary, fewer moving parts. + +**Why SQLite?** One file, zero cgo, online backup, and far more +write headroom than 1,000 services at 5s intervals need. A second +adapter plugs in behind `store.Store` without touching callers. + +**Why is a zero-service config invalid?** A monitor watching nothing is +a scripting bug or a typo away from silence. `epmon init` won't produce +one either. + +**How do I rename a service without losing history?** You don't get it +for free: the old id archives, the new one starts fresh. That's the +exact-history invariant — renames are visible by design. Declare the +rename with `aliases` so the intent is recorded alongside the service. + +**Where do secrets go?** In the environment, referenced as `${VAR}`. +`epmon init` writes `$NAME` as `${NAME}` automatically and never +persists a literal token. + ## Development ``` @@ -226,12 +422,17 @@ internal/store/ ports: domain types + Check/Incident/Service interfaces internal/store/sqlite/ SQLite adapter — the only package owning SQL (+tests) internal/prober/ one HTTP check (+tests) internal/scheduler/ per-service tick loops, graceful shutdown -internal/api/ handlers, middleware, embedded OpenAPI (+tests) +internal/api/ handlers, middleware, embedded OpenAPI + status UI (+tests) +internal/api/webui/ vendored status SPA (refresh with `make web`) internal/metrics/ Prometheus exposition (+tests) +scripts/ check-docs.py (README validation), refresh-webui.sh ``` ```sh -go build ./... && go vet ./... && go test ./... +make check # fmt + vet + race tests (what CI runs) +make check-docs # every README yaml block must validate +make build # stamped local binary +make help # all 19 targets ``` All green with no external services (tests use `httptest` and temp-file diff --git a/internal/api/webui/assets/index-5U1zY8sQ.js b/internal/api/webui/assets/index-CDDGrK5j.js similarity index 82% rename from internal/api/webui/assets/index-5U1zY8sQ.js rename to internal/api/webui/assets/index-CDDGrK5j.js index 069d450..407910f 100644 --- a/internal/api/webui/assets/index-5U1zY8sQ.js +++ b/internal/api/webui/assets/index-CDDGrK5j.js @@ -1,2 +1,2 @@ import{n as e}from"./rolldown-runtime-CbXtAM7H.js";import{n as t,r as n,t as r}from"./react-Dy-GIXkn.js";import{a as i,i as a,n as o,o as s,r as c,s as l,t as u}from"./router-BUCJvZia.js";(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var d=e(n(),1),f=t(),p={slug:`acme-inc`,displayName:`Acme Inc`,tier:`pro`,tagline:`Widgets, shipped reliably.`,logoUrl:null,domains:[`status.acme-inc.com`],customCss:``,hideBranding:!1,services:[`web-app`,`api-gateway`,`cdn-edge`]},m={slug:`globex`,displayName:`Globex`,tier:`team`,tagline:`Global logistics, visibly reliable.`,logoUrl:null,domains:[`status.globex.com`,`status-eu.globex.com`],customCss:`.tenant-accent { color: #1d4ed8; } -.tenant-hero { border-left: 4px solid #1d4ed8; padding-left: 1rem; }`,hideBranding:!0,services:[`web-app`,`api-gateway`,`cdn-edge`]},h={slug:`hobby-demo`,displayName:`Hobby Demo`,tier:`hobby`,tagline:`A community project monitored with epmon.`,logoUrl:null,domains:[],customCss:``,hideBranding:!1,services:[`web-app`,`api-gateway`]},g={hobby:`Hobby`,pro:`Pro`,team:`Team`},_=[h,p,m];function v(){return _}function y(e){return _.find(t=>t.slug===e)}function b(e){let t=e.toLowerCase().split(`:`)[0];return _.find(e=>e.domains.some(e=>e.toLowerCase()===t))}function ee(e){return`/p/${e}`}function te(e){return`/s/${e.domains[0]??e.slug}`}function ne(e){return e.tier===`team`?e.customCss:``}function re(e){return e.tier===`team`&&e.hideBranding}function ie(e){return{id:e.id,name:e.name,url:e.url,up:e.up,latencyMs:e.latency_ms,statusCode:e.status_code,checkedAt:e.checked_at,error:e.error}}function ae(e){return{id:e.id,serviceId:e.service_id,title:e.title,state:e.state,severity:e.severity,createdAt:e.created_at,updatedAt:e.updated_at,updates:(e.updates??[]).map(e=>({ts:e.ts,text:e.text}))}}function oe(e){return e.up===!0?`operational`:e.up===!1?`outage`:`unknown`}function se(){return``}async function x(e,t){let n=await fetch(`${se()}${e}`,{headers:{Accept:`application/json`},cache:`no-store`,signal:t});if(!n.ok)throw Error(`GET ${e} -> ${n.status}`);return await n.json()}function ce(e){if(!e||typeof e.overall!=`string`||!Array.isArray(e.services))throw Error(`bad /api/v1/status shape`);return{checkedAt:e.checked_at,overall:e.overall,services:e.services.map(ie)}}async function S(e){return ce(await x(`/api/v1/status`,e))}async function C(e,t=90,n){let r=await x(`/api/v1/services/${encodeURIComponent(e)}/history?days=${t}`,n);if(!r||!Array.isArray(r.history))throw Error(`bad history shape`);return{serviceId:r.service_id,days:r.days,uptimePct:r.uptime_pct,buckets:r.history.map(e=>({date:e.date,up:e.up,checks:e.checks}))}}async function w(e={},t){let n=new URLSearchParams;e.state&&n.set(`state`,e.state),e.serviceId&&n.set(`service`,e.serviceId),n.set(`limit`,`100`);let r=await x(`/api/v1/incidents${`?${n.toString()}`}`,t);if(!r||!Array.isArray(r.incidents))throw Error(`bad incidents shape`);let i=r.incidents.map(ae);return e.open!==void 0&&(i=i.filter(t=>e.open?t.state!==`resolved`:t.state===`resolved`)),{items:i,total:r.total}}function T(e){if(e===null)return`No data yet`;let t=new Date(e*1e3);return Number.isNaN(t.getTime())?`—`:t.toLocaleString()}function E(e,t){if(e===null)return`never`;let n=Math.max(0,Math.floor(t/1e3)-e);if(n<5)return`just now`;if(n<60)return`${n}s ago`;let r=Math.floor(n/60);if(r<60)return`${r}m ago`;let i=Math.floor(r/60);if(i<24)return`${i}h ago`;let a=Math.floor(i/24);return a===1?`yesterday`:`${a}d ago`}function D(e=1e4){let[t,n]=(0,d.useState)(()=>Date.now());return(0,d.useEffect)(()=>{let t=window.setInterval(()=>n(Date.now()),e);return()=>window.clearInterval(t)},[e]),t}function O(e){let t=new URLSearchParams(e).get(`preview`);return t===`operational`||t===`degraded`||t===`partial_outage`||t===`major_outage`||t===`unknown`?t:t===`outage`?`partial_outage`:null}function k(e,t=15e3){let[n,r]=(0,d.useState)(null),[i,a]=(0,d.useState)(null),[o,s]=(0,d.useState)(null),c=(0,d.useRef)(e);c.current=e;let l=(0,d.useCallback)(async e=>{try{let t=await c.current(e);if(e.aborted)return;r(t),a(null),s(new Date)}catch(t){if(e.aborted)return;a(t instanceof Error?t.message:`request failed`)}},[]);return(0,d.useEffect)(()=>{let e=new AbortController;l(e.signal);let n=window.setInterval(()=>{let e=new AbortController;l(e.signal)},t);return()=>{e.abort(),window.clearInterval(n)}},[l,t]),{data:n,error:i,refreshedAt:o}}var A={operational:`All systems operational`,degraded:`Degraded performance`,partial_outage:`Partial outage`,major_outage:`Major outage`,unknown:`Status unknown`},le={operational:`Operational`,outage:`Outage`,unknown:`Unknown`},j=r(),ue=[`investigating`,`monitoring`,`resolved`],de={critical:`bg-red-500`,major:`bg-orange-500`,minor:`bg-amber-500`},M=`rounded-lg px-2.5 py-1.5 text-sm font-medium`;function N({incidents:e,serviceIds:t,basePath:n}){let[r,i]=l(),a=D(),s=r.get(`service_id`)??``,c=r.get(`state`)??``,u=r.get(`open`)===`true`,d=r.get(`incident`),f=e.filter(e=>{if(s===`__platform`){if(e.serviceId!==void 0)return!1}else if(s&&e.serviceId!==s)return!1;return!(c&&e.state!==c||u&&e.state===`resolved`)}),p=f.find(e=>String(e.id)===d)??null,m=(e,t)=>{let n=new URLSearchParams(r);t===null||t===``?n.delete(e):n.set(e,t),i(n,{replace:!0})};return(0,j.jsxs)(`section`,{id:`incidents`,"aria-labelledby":`incidents-h`,className:`scroll-mt-6 rounded-2xl p-5 sm:p-6`,style:{border:`1px solid var(--card-border)`,background:`var(--card-bg)`},children:[(0,j.jsxs)(`div`,{className:`flex flex-wrap items-end justify-between gap-3`,children:[(0,j.jsx)(`h2`,{id:`incidents-h`,className:`text-xl font-bold tracking-tight`,children:`Incidents`}),(0,j.jsxs)(`span`,{className:`tabular font-mono text-xs`,style:{color:`var(--muted-fg)`},children:[f.length,` shown`]})]}),(0,j.jsxs)(`form`,{className:`mt-4 flex flex-wrap items-end gap-2.5 text-sm`,"aria-label":`Filter incidents`,onSubmit:e=>e.preventDefault(),children:[(0,j.jsxs)(`label`,{className:`flex flex-col gap-1.5 font-medium`,children:[(0,j.jsx)(`span`,{className:`text-xs`,style:{color:`var(--muted-fg)`},children:`Service`}),(0,j.jsxs)(`select`,{value:s,onChange:e=>m(`service_id`,e.target.value||null),className:M,style:{border:`1px solid var(--card-border)`,background:`var(--card-bg)`,color:`inherit`},children:[(0,j.jsx)(`option`,{value:``,children:`All services`}),t.map(e=>(0,j.jsx)(`option`,{value:e,children:e},e)),(0,j.jsx)(`option`,{value:`__platform`,children:`Platform-wide`})]})]}),(0,j.jsxs)(`label`,{className:`flex flex-col gap-1.5 font-medium`,children:[(0,j.jsx)(`span`,{className:`text-xs`,style:{color:`var(--muted-fg)`},children:`State`}),(0,j.jsxs)(`select`,{value:c,onChange:e=>m(`state`,e.target.value||null),className:M,style:{border:`1px solid var(--card-border)`,background:`var(--card-bg)`,color:`inherit`},children:[(0,j.jsx)(`option`,{value:``,children:`Any state`}),ue.map(e=>(0,j.jsx)(`option`,{value:e,children:e},e))]})]}),(0,j.jsxs)(`label`,{className:`flex cursor-pointer items-center gap-2 pb-2 font-medium`,children:[(0,j.jsx)(`input`,{type:`checkbox`,checked:u,onChange:e=>m(`open`,e.target.checked?`true`:null),className:`h-4 w-4 accent-emerald-600`}),`Open only`]})]}),f.length===0?(0,j.jsx)(`p`,{className:`mt-4 rounded-xl p-5 text-center text-sm`,style:{background:`var(--pill-bg)`,color:`var(--muted-fg)`},children:`Nothing here. When something breaks, the entry lands in this feed with its full update thread.`}):(0,j.jsx)(`ul`,{className:`mt-4 space-y-2.5`,children:f.map(e=>(0,j.jsx)(`li`,{children:(0,j.jsxs)(o,{to:`${n}?incident=${e.id}`,className:`flex items-start gap-3 rounded-xl p-3.5 transition hover:ring-1`,style:{background:`var(--pill-bg)`},children:[(0,j.jsx)(`span`,{"aria-hidden":`true`,className:`mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full ${de[e.severity]}`}),(0,j.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,j.jsxs)(`span`,{className:`block truncate font-semibold`,children:[`#`,e.id,` — `,e.title]}),(0,j.jsxs)(`span`,{className:`tabular mt-0.5 block font-mono text-xs`,style:{color:`var(--muted-fg)`},children:[e.state,` · `,e.severity,` · `,e.serviceId??`platform-wide`,` · `,E(e.createdAt,a)]})]})]})},e.id))}),p&&(0,j.jsxs)(`article`,{"aria-label":`Incident ${p.id}`,className:`mt-4 rounded-xl p-4 sm:p-5`,style:{border:`1px solid var(--card-border)`},children:[(0,j.jsxs)(`h3`,{className:`text-lg font-bold tracking-tight`,children:[`#`,p.id,` — `,p.title]}),(0,j.jsxs)(`p`,{className:`tabular mt-1 font-mono text-xs`,style:{color:`var(--muted-fg)`},children:[p.state,` · `,p.severity,` · opened `,T(p.createdAt),p.state===`resolved`&&(0,j.jsxs)(j.Fragment,{children:[` · resolved `,T(p.updatedAt)]})]}),(0,j.jsx)(`ol`,{className:`mt-3 space-y-2.5`,children:p.updates.map((e,t)=>(0,j.jsxs)(`li`,{className:`border-l-2 pl-3 text-sm leading-relaxed`,style:{borderColor:`var(--card-border)`},children:[(0,j.jsx)(`span`,{className:`tabular mr-2 font-mono text-xs`,style:{color:`var(--muted-fg)`},children:T(e.ts)}),e.text]},`${e.ts}-${t}`))})]})]})}var P={operational:`bg-emerald-500`,degraded:`bg-amber-500`,partial_outage:`bg-orange-500`,major_outage:`bg-red-500`,unknown:`bg-zinc-500`},F={operational:`rgba(16, 185, 129, 0.16)`,degraded:`rgba(245, 158, 11, 0.16)`,partial_outage:`rgba(249, 115, 22, 0.18)`,major_outage:`rgba(239, 68, 68, 0.20)`,unknown:`rgba(113, 113, 122, 0.16)`};function I({overall:e,checkedAt:t,live:n,now:r}){return(0,j.jsxs)(`section`,{role:`status`,"aria-live":`polite`,"aria-label":`Overall status: ${A[e]}`,className:`relative overflow-hidden rounded-3xl px-6 py-10 text-center sm:py-14`,style:{border:`1px solid var(--card-border)`,background:`var(--card-bg)`},children:[(0,j.jsx)(`div`,{"aria-hidden":`true`,className:`pointer-events-none absolute inset-x-0 -top-24 mx-auto h-64 max-w-xl rounded-full blur-3xl`,style:{background:F[e]}}),(0,j.jsxs)(`div`,{className:`relative`,children:[(0,j.jsxs)(`p`,{className:`inline-flex items-center gap-2 rounded-full px-3.5 py-1.5 font-mono text-xs`,style:{border:`1px solid var(--card-border)`,background:`var(--pill-bg)`},children:[(0,j.jsxs)(`span`,{className:`relative flex h-2 w-2`,children:[(0,j.jsx)(`span`,{className:`absolute inline-flex h-full w-full animate-ping rounded-full opacity-60 ${P[e]}`}),(0,j.jsx)(`span`,{className:`relative inline-flex h-2 w-2 rounded-full ${P[e]}`})]}),n?`LIVE`:`DEMO`,(0,j.jsxs)(`span`,{style:{color:`var(--muted-fg)`},children:[`· updated `,E(t,r)]})]}),(0,j.jsx)(`h2`,{className:`mx-auto mt-5 max-w-2xl text-4xl font-extrabold leading-[1.05] tracking-tight sm:text-5xl`,children:A[e]}),(0,j.jsx)(`p`,{className:`mx-auto mt-3 max-w-md text-sm leading-relaxed`,style:{color:`var(--muted-fg)`},children:`Gaps in monitoring show as gaps, never as green.`})]})]})}function L(e){return e.up===null?`${e.date}: no data — the monitor was down, not necessarily the service`:`${e.date}: ${e.up?`up`:`down`}, ${e.checks.toLocaleString()} checks`}function R(e){return e.up===null?`${e.date} · no data`:`${e.date} · ${e.up?`up`:`down`} · ${e.checks.toLocaleString()} checks`}function z({buckets:e,serviceName:t,hovered:n,onHover:r}){return(0,j.jsxs)(`div`,{onMouseLeave:()=>r(null),children:[(0,j.jsx)(`p`,{className:`tabular h-5 truncate font-mono text-xs`,style:{color:`var(--muted-fg)`},"aria-live":`polite`,children:n!==null&&e[n]?R(e[n]):`Hover any day for the exact numbers`}),(0,j.jsx)(`div`,{className:`mt-1.5 flex h-10 items-stretch gap-[2px]`,role:`img`,"aria-label":`History for ${t}`,children:e.map((e,t)=>(0,j.jsx)(`div`,{title:L(e),onMouseEnter:()=>r(t),onFocus:()=>r(t),tabIndex:0,className:`daybar min-w-0 flex-1 cursor-crosshair rounded-[3px] ${e.up===null?`bar--nodata`:e.up?`bar--up`:`bar--outage`}`},e.date))}),(0,j.jsxs)(`ul`,{className:`mt-2.5 flex flex-wrap gap-x-4 gap-y-1 text-xs`,style:{color:`var(--muted-fg)`},"aria-label":`Legend`,children:[(0,j.jsxs)(`li`,{className:`inline-flex items-center gap-1.5`,children:[(0,j.jsx)(`span`,{"aria-hidden":`true`,className:`bar--up inline-block h-2 w-2 rounded-sm`}),` Up`]}),(0,j.jsxs)(`li`,{className:`inline-flex items-center gap-1.5`,children:[(0,j.jsx)(`span`,{"aria-hidden":`true`,className:`bar--outage inline-block h-2 w-2 rounded-sm`}),` Down`]}),(0,j.jsxs)(`li`,{className:`inline-flex items-center gap-1.5`,children:[(0,j.jsx)(`span`,{"aria-hidden":`true`,className:`bar--nodata inline-block h-2 w-2 rounded-sm`}),` No data`]})]})]})}var B={operational:`bg-emerald-500`,outage:`bg-red-500`,unknown:`bg-zinc-500`};function V({service:e,history:t,activeIncidentIds:n,now:r}){let[i,a]=(0,d.useState)(null),o=oe(e),s=t?.buckets??[],c=t?.uptimePct,l=s.filter(e=>e.up!==null).length;return(0,j.jsxs)(`article`,{"aria-label":e.name,className:`rounded-2xl p-5 transition sm:p-6`,style:{border:`1px solid var(--card-border)`,background:`var(--card-bg)`},children:[(0,j.jsxs)(`div`,{className:`flex flex-wrap items-center justify-between gap-3`,children:[(0,j.jsxs)(`div`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,j.jsxs)(`span`,{className:`relative flex h-2.5 w-2.5 shrink-0`,children:[(0,j.jsx)(`span`,{className:`absolute inline-flex h-full w-full animate-ping rounded-full opacity-60 ${B[o]}`}),(0,j.jsx)(`span`,{className:`relative inline-flex h-2.5 w-2.5 rounded-full ${B[o]}`})]}),(0,j.jsx)(`h3`,{className:`truncate text-lg font-bold tracking-tight`,children:e.name})]}),(0,j.jsx)(`span`,{className:`shrink-0 rounded-full px-3 py-1 text-xs font-semibold`,style:{border:`1px solid var(--card-border)`,background:`var(--pill-bg)`},children:le[o]})]}),(0,j.jsxs)(`p`,{className:`tabular mt-2.5 font-mono text-xs`,style:{color:`var(--muted-fg)`},children:[e.up===!0&&e.latencyMs!==null&&(0,j.jsxs)(j.Fragment,{children:[e.latencyMs,` ms · HTTP `,e.statusCode??`?`,` · checked `,E(e.checkedAt,r)]}),e.up===!1&&(0,j.jsxs)(j.Fragment,{children:[`DOWN`,e.error?` (${e.error})`:``,` · checked `,E(e.checkedAt,r)]}),e.up===null&&`No checks yet — unknown until the first one completes`]}),n.length>0&&(0,j.jsxs)(`p`,{className:`mt-2.5 rounded-xl px-3 py-2 text-sm`,style:{background:`var(--warn-bg)`,color:`var(--warn-fg)`},children:[`Active incident:`,` `,(0,j.jsxs)(`a`,{className:`font-semibold underline underline-offset-2`,href:`#incidents`,children:[`#`,n.join(`, #`)]})]}),(0,j.jsx)(`div`,{className:`mt-4`,children:t?(0,j.jsx)(z,{buckets:s,serviceName:e.name,hovered:i,onHover:a}):(0,j.jsx)(`p`,{className:`text-sm`,style:{color:`var(--muted-fg)`},children:`Loading history…`})}),(0,j.jsxs)(`div`,{className:`mt-3.5 flex flex-wrap items-baseline justify-between gap-2 border-t pt-3.5`,style:{borderColor:`var(--card-border)`},children:[(0,j.jsxs)(`span`,{className:`text-xs`,style:{color:`var(--muted-fg)`},children:[`Last 90 days · `,l,` with data`]}),(0,j.jsx)(`span`,{className:`tabular font-mono text-base font-bold`,children:c==null?`—`:`${c.toFixed(2)}%`})]})]})}var H=e=>`epmon:notify:${e}`;function fe(e){try{let t=window.localStorage.getItem(H(e));if(t)return JSON.parse(t)}catch{}return{enabled:!1,lastOverall:null,seenIds:[]}}function pe({slug:e,tenantName:t}){let[n,r]=(0,d.useState)(()=>typeof Notification>`u`?`unsupported`:`prompt`),i=(0,d.useRef)(fe(e));(0,d.useEffect)(()=>{if(typeof Notification>`u`){r(`unsupported`);return}Notification.permission===`denied`?(i.current.enabled=!1,r(`denied`)):i.current.enabled&&Notification.permission===`granted`?r(`on`):r(`off`)},[]);let a=(0,d.useCallback)(t=>{i.current=t;try{window.localStorage.setItem(H(e),JSON.stringify(t))}catch{}},[e]);(0,d.useEffect)(()=>{if(n!==`on`)return;let e=!1;async function r(){try{let[n,r]=await Promise.all([S(),w({open:!0})]);if(e)return;let o=i.current,s=[];o.lastOverall!==null&&o.lastOverall!==n.overall&&s.push(`${t}: status changed to ${n.overall.replaceAll(`_`,` `)}`);let c=r.items.filter(e=>!o.seenIds.includes(e.id));for(let e of c.slice(0,3))s.push(`#${e.id} ${e.title}`);a({enabled:!0,lastOverall:n.overall,seenIds:r.items.map(e=>e.id)});for(let e of s)try{new Notification(t,{body:e})}catch{}}catch{}}r();let o=window.setInterval(r,6e4);return()=>{e=!0,window.clearInterval(o)}},[n,a,t]);let o=(0,d.useCallback)(async()=>{if(typeof Notification>`u`)return;let e=await Notification.requestPermission();e===`granted`?(a({enabled:!0,lastOverall:null,seenIds:[]}),r(`on`)):e===`denied`&&(a({enabled:!1,lastOverall:null,seenIds:[]}),r(`denied`))},[a]),s=(0,d.useCallback)(()=>{a({enabled:!1,lastOverall:null,seenIds:[]}),r(`off`)},[a]);return n===`unsupported`?null:(0,j.jsxs)(`section`,{"aria-labelledby":`subscribe-h`,className:`rounded-2xl p-5 sm:p-6`,style:{border:`1px solid var(--card-border)`,background:`var(--card-bg)`},children:[(0,j.jsx)(`h2`,{id:`subscribe-h`,className:`text-xl font-bold tracking-tight`,children:`Stay informed`}),(0,j.jsx)(`p`,{className:`mt-1.5 text-sm leading-relaxed`,style:{color:`var(--muted-fg)`},children:n===`on`?`This browser notifies you when the status changes or a new incident appears. Keep the page open in a tab.`:n===`denied`?`Notifications are blocked for this site. Re-enable them in your browser’s site settings, then come back.`:`Get a notification in this browser when the status changes or a new incident appears. No account, nothing leaves your device.`}),(0,j.jsx)(`div`,{className:`mt-4`,children:n===`on`?(0,j.jsx)(`button`,{type:`button`,onClick:s,className:`rounded-full px-5 py-2.5 text-sm font-semibold transition`,style:{border:`1px solid var(--card-border)`},children:`Turn off notifications`}):n!==`denied`&&(0,j.jsx)(`button`,{type:`button`,onClick:o,className:`rounded-full bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-emerald-500`,children:`Notify me in this browser`})})]})}function me({tenant:e,customDomain:t}){let n=ne(e),r=re(e),i=e.domains[0];return(0,j.jsxs)(`header`,{children:[n&&(0,j.jsx)(`style`,{children:n}),(0,j.jsxs)(`div`,{className:`flex items-center gap-3.5`,children:[e.logoUrl?(0,j.jsx)(`img`,{src:e.logoUrl,alt:`${e.displayName} logo`,className:`h-11 w-11 rounded-xl object-cover`}):(0,j.jsx)(`span`,{"aria-hidden":`true`,className:`flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-700 text-xl font-bold text-white`,children:e.displayName.slice(0,1)}),(0,j.jsxs)(`div`,{className:`min-w-0`,children:[(0,j.jsxs)(`h1`,{className:`tenant-hero truncate text-2xl font-bold tracking-tight`,children:[e.displayName,` `,(0,j.jsx)(`span`,{className:`font-medium opacity-60`,children:`status`})]}),(0,j.jsx)(`p`,{className:`truncate text-sm`,style:{color:`var(--muted-fg)`},children:e.tagline})]})]}),t&&i&&(0,j.jsxs)(`p`,{className:`mt-3 inline-flex items-center gap-1.5 text-xs`,style:{color:`var(--muted-fg)`},children:[(0,j.jsxs)(`svg`,{"aria-hidden":`true`,width:`12`,height:`12`,viewBox:`0 0 16 16`,fill:`none`,children:[(0,j.jsx)(`rect`,{x:`3`,y:`7`,width:`10`,height:`7`,rx:`1.5`,stroke:`currentColor`,strokeWidth:`1.5`}),(0,j.jsx)(`path`,{d:`M5.5 7V5a2.5 2.5 0 0 1 5 0v2`,stroke:`currentColor`,strokeWidth:`1.5`})]}),i,` · secured with automatic TLS`]}),!r&&(0,j.jsx)(`p`,{className:`mt-3 text-xs`,style:{color:`var(--muted-fg)`},children:`Powered by epmon.dev`})]})}function he(e){let t=e>>>0;return()=>{t|=0,t=t+1831565813|0;let e=Math.imul(t^t>>>15,1|t);return e=e+Math.imul(e^e>>>7,61|e)^e,((e^e>>>14)>>>0)/4294967296}}function U(e){return e.toISOString().slice(0,10)}function ge(e){let t=2166136261;for(let n=0;n>>0}var W=Math.floor(Date.now()/1e3);function G(e){let t=new Date,n=new Date(Date.UTC(t.getUTCFullYear(),t.getUTCMonth(),t.getUTCDate()));return n.setUTCDate(n.getUTCDate()-e),n}var K=e=>Math.floor(e.getTime()/1e3);function q(e,t=90){let n=he(ge(e)),r=[],i=0,a=0;for(let o=t-1;o>=0;o--){let s=G(o),c=t-1-o;if(e===`cdn-edge`&&c<14){r.push({date:U(s),up:null,checks:0});continue}let l=n(),u=e===`api-gateway`&&o>=8&&o<=10,d=e===`cdn-edge`?720:2880,f;f=u?o!==9:!(l<.03);let p=f?d:Math.floor(d*(.9+n()*.08));i++,f&&a++,r.push({date:U(s),up:f,checks:p})}return{serviceId:e,days:t,uptimePct:i>0?Math.round(100*a/i*100)/100:null,buckets:r}}var J=W-42;function _e(){return[{id:`web-app`,name:`Web App`,url:`https://example.com`,up:!0,latencyMs:87,statusCode:200,checkedAt:J},{id:`api-gateway`,name:`API Gateway`,url:`https://api.example.com/readyz`,up:!1,latencyMs:5e3,statusCode:0,checkedAt:J,error:`unexpected_status: upstream 503s`},{id:`cdn-edge`,name:`Edge CDN`,url:`https://cdn.example.com`,up:!0,latencyMs:24,statusCode:200,checkedAt:J}]}function ve(e){let t=e.filter(e=>e.up!==null);return t.length===0?`unknown`:t.every(e=>e.up===!1)?`major_outage`:t.some(e=>e.up===!1)?`partial_outage`:`operational`}function Y(e=null){let t=_e();return e===`operational`?t=t.map(e=>({...e,up:!0,error:void 0})):e===`degraded`?t=t.map((e,t)=>t===1?e:{...e,up:!0,error:void 0}):e===`partial_outage`?t=t.map((e,t)=>t===0?{...e,up:!1,error:`connection refused`}:{...e,up:!0,error:void 0}):e===`major_outage`?t=t.map(e=>({...e,up:!1,error:`connection refused`})):e===`unknown`&&(t=t.map(e=>({...e,up:null,latencyMs:null,statusCode:null,checkedAt:null,error:void 0}))),{checkedAt:W,overall:e??ve(t),services:t}}var ye=[{id:101,serviceId:`api-gateway`,title:`Elevated latency on API Gateway`,state:`monitoring`,severity:`major`,createdAt:K(G(1))-21600,updatedAt:K(G(1))-7200,updates:[{ts:K(G(1))-7200,text:`Latency is back within SLO; monitoring before resolving.`},{ts:K(G(1))-21600,text:`Automated alert: probe failed — unexpected_status: upstream 503s.`}]},{id:99,serviceId:`api-gateway`,title:`Upstream 5xx spike`,state:`resolved`,severity:`major`,createdAt:K(G(9)),updatedAt:K(G(8)),updates:[{ts:K(G(8)),text:`Automated recovery: service is responding normally.`}]},{id:87,title:`Scheduled database maintenance`,state:`resolved`,severity:`minor`,createdAt:K(G(20)),updatedAt:K(G(20))+3600,updates:[{ts:K(G(20))+3600,text:`Maintenance completed ahead of schedule; no customer impact observed.`}]}];function X(e={}){let t=[...ye];return e.serviceId&&(t=t.filter(t=>t.serviceId===e.serviceId)),e.state&&(t=t.filter(t=>t.state===e.state)),e.open!==void 0&&(t=t.filter(t=>e.open?t.state!==`resolved`:t.state===`resolved`)),t.sort((e,t)=>t.createdAt-e.createdAt),{items:t,total:t.length}}function Z({tenant:e,customDomain:t,basePath:n}){let{search:r}=i(),a=O(r),o=D(),[s,c]=(0,d.useState)(90),l=k(async e=>{if(a)return Y(a);try{return await S(e)}catch{return Y(null)}},15e3),u=l.data,f=a===null&&l.error===null,p=(0,d.useMemo)(()=>(u?.services??[]).filter(t=>e.services.includes(t.id)),[u,e]),[m,h]=(0,d.useState)({});(0,d.useEffect)(()=>{let t=!1;async function n(){let n=await Promise.all(e.services.map(async e=>{if(a)return[e,q(e,s)];try{return[e,await C(e,s)]}catch{return[e,q(e,s)]}}));t||h(Object.fromEntries(n))}return n(),()=>{t=!0}},[e,s,a]);let[g,_]=(0,d.useState)(null);(0,d.useEffect)(()=>{let e=!1;async function t(){if(a){e||_(X());return}try{let t=await w();e||_(t)}catch{e||_(X())}}return t(),()=>{e=!0}},[a]);let v=(0,d.useMemo)(()=>(g?.items??[]).filter(t=>t.serviceId===void 0||e.services.includes(t.serviceId)),[g,e]),y=(0,d.useMemo)(()=>{let e={};for(let t of v)t.state!==`resolved`&&t.serviceId!==void 0&&(e[t.serviceId]??=[]).push(t.id);return e},[v]),b=v.filter(e=>e.state!==`resolved`&&e.serviceId===void 0);return(0,j.jsxs)(`div`,{className:`mx-auto max-w-4xl space-y-6 px-4 py-8 sm:py-12`,children:[(0,j.jsx)(me,{tenant:e,customDomain:t}),(0,j.jsxs)(`main`,{"aria-label":`${e.displayName} status`,className:`space-y-6`,children:[u?(0,j.jsx)(I,{overall:u.overall,checkedAt:u.checkedAt,live:f,now:o}):(0,j.jsx)(`p`,{role:`status`,className:`rounded-2xl p-8 text-center text-sm`,style:{border:`1px solid var(--card-border)`,background:`var(--card-bg)`,color:`var(--muted-fg)`},children:l.error?`Status unavailable (${l.error}); retrying…`:`Loading status…`}),(b.length>0||p.some(e=>(y[e.id]??[]).length>0))&&(0,j.jsxs)(`section`,{"aria-label":`Active incidents`,className:`rounded-2xl p-4 sm:p-5`,style:{background:`var(--warn-bg)`,color:`var(--warn-fg)`},children:[(0,j.jsx)(`h2`,{className:`font-bold`,children:b.length>0?`Active incident: ${b[0].title}`:`Active incident — see the affected service below`}),b.length>0&&b[0].updates[0]&&(0,j.jsx)(`p`,{className:`mt-1 text-sm opacity-90`,children:b[0].updates[0].text}),(0,j.jsx)(`p`,{className:`mt-2 text-sm`,children:(0,j.jsx)(`a`,{className:`font-semibold underline underline-offset-2`,href:`#incidents`,children:`Follow along in the incident feed ↓`})})]}),(0,j.jsxs)(`div`,{className:`flex flex-wrap items-end justify-between gap-3`,children:[(0,j.jsx)(`h2`,{className:`text-xl font-bold tracking-tight`,children:`Services`}),(0,j.jsx)(`div`,{role:`group`,"aria-label":`History range`,className:`inline-flex rounded-full p-1`,style:{border:`1px solid var(--card-border)`},children:[30,90].map(e=>(0,j.jsxs)(`button`,{type:`button`,"aria-pressed":s===e,onClick:()=>c(e),className:`tabular rounded-full px-4 py-1.5 font-mono text-xs font-semibold transition`,style:s===e?{background:`var(--fg-active)`,color:`var(--bg-active)`}:{color:`var(--muted-fg)`},children:[e,`d`]},e))})]}),p.map(e=>(0,j.jsx)(V,{service:e,history:m[e.id]??null,activeIncidentIds:y[e.id]??[],now:o},e.id)),(0,j.jsx)(N,{incidents:v,serviceIds:e.services,basePath:n}),(0,j.jsx)(pe,{slug:e.slug,tenantName:e.displayName}),(0,j.jsxs)(`p`,{className:`tabular text-center font-mono text-xs`,style:{color:`var(--muted-fg)`},children:[`Updated `,u?E(u.checkedAt,o):`—`,` · refreshes automatically`,a?` · preview mode (?preview=${a})`:``]})]}),(0,j.jsxs)(`footer`,{className:`border-t pt-4 text-center text-xs`,style:{borderColor:`var(--card-border)`,color:`var(--muted-fg)`},children:[e.domains[0]??`epmon.dev/p/${e.slug}`,` ·`,` `,new Date().getUTCFullYear(),` `,e.displayName]})]})}function Q(){let{domain:e=``}=s(),t=b(e)??y(e);if(!t)return(0,j.jsxs)(`main`,{className:`mx-auto max-w-2xl px-4 py-16 text-center sm:py-24`,children:[(0,j.jsx)(`p`,{className:`font-mono text-xs font-semibold uppercase tracking-[0.25em]`,style:{color:`var(--muted-fg)`},children:`404`}),(0,j.jsx)(`h1`,{className:`mt-3 text-4xl font-extrabold tracking-tight`,children:`No page on this domain.`}),(0,j.jsxs)(`p`,{className:`mt-3 text-sm`,style:{color:`var(--muted-fg)`},children:[`“`,e,`” isn’t serving a status page here. Configured demo domains:`]}),(0,j.jsx)(`ul`,{className:`mt-6 space-y-2`,children:v().flatMap(e=>e.domains.map(t=>(0,j.jsxs)(`li`,{children:[(0,j.jsx)(o,{className:`font-medium underline underline-offset-4`,to:`/s/${t}`,children:t}),` `,(0,j.jsxs)(`span`,{className:`text-sm`,style:{color:`var(--muted-fg)`},children:[`(`,e.displayName,`)`]})]},t)))})]});let n=t.domains[0]??t.slug;return(0,j.jsx)(Z,{tenant:t,customDomain:!0,basePath:`/s/${n}`})}function be(){let[e,t]=(0,d.useState)(`acme-inc`),n=`
\n + diff --git a/internal/api/webui/source.txt b/internal/api/webui/source.txt index a343abb..23959eb 100644 --- a/internal/api/webui/source.txt +++ b/internal/api/webui/source.txt @@ -1,2 +1,2 @@ origin: epmon-dev/status @ 0fa682b-dirty -refreshed: 2026-09-20T13:43:55Z +refreshed: 2026-09-20T14:32:59Z diff --git a/pkg/store/store.go b/pkg/store/store.go index 2899c60..9e7d0ac 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -97,7 +97,7 @@ type IncidentUpdate struct { } // DayRollup is one calendar-day bucket. UptimePct, AvgLatencyMs and -// P95LatencyMs are nil iff Total == 0 (honest null, never fabricated). +// P95LatencyMs are nil iff Total == 0 (null, never fabricated). type DayRollup struct { ServiceID string `json:"serviceId"` Date string `json:"date"`