diff --git a/README.md b/README.md
index b09d19d..91ccf01 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# API Health Monitor
-A local API monitor built with JavaScript, Node.js, Express and SQLite. Checks HTTP status, response time and optional JSON values. Three consecutive failures open an incident; the next successful check resolves it.
+A JavaScript monitor built with Node.js, Express and SQLite. Check HTTP status, response time and JSON values; three consecutive failures open an incident, and a successful check resolves it.
## Run
@@ -11,51 +11,28 @@ npm ci
npm start
```
-The API listens at `http://localhost:3000`. Data is saved to `data/monitor.db`. Set `PORT` or `DB_PATH` to override either default. Use `npm run dev` to restart on file changes.
+Open `http://localhost:3000` for the interactive dashboard and API. Monitor data is saved to `data/monitor.db`.
-## Try it
+## API
```sh
curl -X POST http://localhost:3000/monitors \
-H 'Content-Type: application/json' \
- -d '{"name":"Example","url":"https://example.com","intervalSeconds":60}'
-
-curl http://localhost:3000/monitors
-curl -X POST http://localhost:3000/monitors/ID/check
-curl http://localhost:3000/monitors/ID/checks
-curl http://localhost:3000/monitors/ID/incidents
+ -d '{"name":"Example","url":"https://example.com"}'
```
-Replace `ID` with the ID returned when creating a monitor.
-
| Method | Route | Purpose |
| --- | --- | --- |
-| GET | `/health` | Service health |
-| GET, POST | `/monitors` | List or create monitors |
-| GET, PATCH, DELETE | `/monitors/:id` | Read, edit or delete a monitor |
-| POST | `/monitors/:id/check` | Run a check now |
-| GET | `/monitors/:id/checks` | Recent results |
+| GET, POST | `/monitors` | List or create |
+| GET, PATCH, DELETE | `/monitors/:id` | View, edit or delete |
+| POST | `/monitors/:id/check` | Run a check |
+| GET | `/monitors/:id/checks` | Check history |
| GET | `/monitors/:id/incidents` | Incident history |
-History routes accept `?limit=50` (maximum 100), newest first.
-
-Optional fields: `expectedStatus` (200), `timeoutMs` (5000), `maxResponseMs`, `enabled` (true), and `expectedJson`, such as `{"path":"data.ready","equals":true}`. JSON comparisons preserve types. Intervals range from 10 seconds to 24 hours; timeouts from 100 to 30,000 ms and cannot exceed the interval.
-
-## Behaviour
+Intervals range from 10 seconds to 24 hours. Optional rules include `expectedStatus`, `maxResponseMs`, and `expectedJson`, for example `{"path":"data.ready","equals":true}`. Targets must resolve to public IP addresses. Redirects are rejected and responses are capped at 64 KiB.
-- At most four checks run at once, with one per monitor. Busy manual requests return 409 or 429. Checks have no immediate retries.
-- New monitors are checked on the next scheduler tick. Later checks wait one interval after completion. Pausing stops scheduled checks; manual checks still work.
-- State and history survive restarts. Editing resets the failure streak and closes open incidents as `configuration_changed`. Results from older configurations are discarded.
-- Up to 100 monitors, 1,000 results and 100 incidents per monitor are retained. Deleting a monitor removes its history.
-- Targets must resolve only to public addresses. Connections use the validated address, redirects are rejected, and responses are capped at 64 KiB. Deadlines include DNS and response reading.
+## Demo and tests
-This is a single-process tool with no login or frontend. It binds to loopback and rejects remote browser origins. Checks run from the machine hosting it; monitoring stops when that process or computer stops.
-
-## Tests and demo
-
-```sh
-npm test
-npm run demo
-```
+The dashboard runs healthy, wrong-JSON, slow-response, and outage-and-recovery scenarios with fixed sample responses. It uses the real checker and incident logic without contacting outside sites. `npm run demo` runs a terminal version; `npm test` runs the test suite.
-The demo runs a local fixture through the API, checker and database: success, wrong JSON, HTTP 503, timeout, recovery. It uses an isolated test transport; the normal server still blocks local targets. Tests cover validation, network failures, history, scheduling, incidents, persistence and shutdown. GitHub Actions runs both commands.
+For hosting, `render.yaml` configures a free Render web service from `demo-dashboard` with manual deployments. It sets `HOST=0.0.0.0` and `PUBLIC_DEMO=true`. Binding to a non-loopback address disables monitor management and scheduled checks. Only the dashboard and fixed demo routes are exposed. Demo state resets each run. `PUBLIC_DEMO=false` disables the dashboard.
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..9b8b114
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,129 @@
+const form = document.querySelector('#scenario-select');
+const button = document.querySelector('#run-button');
+const description = document.querySelector('#scenario-description');
+const scenarioDetails = new Map();
+
+function element(tag, className, text) {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text !== undefined) node.textContent = text;
+ return node;
+}
+
+function setRunState(text, state) {
+ const label = document.querySelector('#run-state');
+ label.className = `run-state ${state}`;
+ label.replaceChildren(element('span', 'state-dot'), document.createTextNode(text));
+}
+
+function formatTime(value) {
+ if (!value) return '—';
+ return new Intl.DateTimeFormat(undefined, { minute: '2-digit', second: '2-digit' }).format(new Date(value));
+}
+
+function resultLabel(check) {
+ if (check.failureCode === 'timeout') return { text: 'Timed out', style: 'failed' };
+ if (check.failureCode === 'slow_response') return { text: 'Too slow', style: 'slow' };
+ if (check.failureCode === 'status_mismatch') return { text: `HTTP ${check.statusCode}`, style: 'failed' };
+ if (check.failureCode === 'json_mismatch') return { text: 'Wrong JSON', style: 'failed' };
+ if (check.failureCode) return { text: 'Check failed', style: 'failed' };
+ return { text: 'Passed', style: '' };
+}
+
+function renderChecks(checks) {
+ const list = document.querySelector('#activity-list');
+ list.replaceChildren();
+ for (const [index, check] of checks.entries()) {
+ const label = resultLabel(check);
+ const row = element('div', 'activity-row');
+ const name = element('div', 'check-name');
+ name.append(element('span', 'check-index', String(index + 1).padStart(2, '0')));
+ name.append(element('span', '', check.label));
+ const result = element('span', `check-result ${label.style}`, label.text);
+ const duration = element('span', 'check-time', check.durationMs === null ? '—' : `${Math.round(check.durationMs)} ms`);
+ row.append(name, result, duration);
+ list.append(row);
+ }
+}
+
+function render(data) {
+ const monitor = data.monitor;
+ const status = document.querySelector('#service-status');
+ const isUp = monitor.status === 'up';
+ const isDown = monitor.status === 'down';
+ const hasRecovered = data.incidents.some(incident => incident.resolvedAt);
+ status.className = `status-value ${isUp ? 'up' : isDown ? 'down' : 'pending'}`;
+ status.textContent = isUp ? 'Operational' : isDown ? 'Incident open' : 'Degraded';
+ document.querySelector('#service-subtitle').textContent = isUp
+ ? `${hasRecovered ? 'Recovered' : 'Healthy'} · last checked ${formatTime(monitor.lastCheckedAt)}`
+ : isDown ? 'Three checks failed in a row' : 'A check did not meet the health rule';
+ document.querySelector('#check-count').textContent = data.checks.length;
+ document.querySelector('#failure-count').textContent = monitor.consecutiveFailures;
+ document.querySelector('#failure-caption').textContent = monitor.status === 'down' ? 'Incident opened' : 'Incident opens at 3';
+ document.querySelector('#activity-footer').textContent = `${data.checks.length} ${data.checks.length === 1 ? 'check' : 'checks'} recorded · ${data.checks.filter(check => check.ok).length} passed · ${data.checks.filter(check => !check.ok).length} failed`;
+ setRunState('Recording complete', 'finished');
+ renderChecks(data.checks);
+ const panel = document.querySelector('#incident-panel');
+ const incident = data.incidents[0];
+ panel.hidden = !incident;
+ if (incident) {
+ const resolved = Boolean(incident.resolvedAt);
+ document.querySelector('#incident-title').textContent = resolved ? 'Incident resolved' : 'Incident opened';
+ document.querySelector('#incident-detail').textContent = resolved
+ ? `Opened ${formatTime(incident.openedAt)} · resolved ${formatTime(incident.resolvedAt)}`
+ : `Opened ${formatTime(incident.openedAt)} after 3 consecutive failures`;
+ const badge = document.querySelector('#incident-status');
+ badge.textContent = resolved ? 'Resolved' : 'Open';
+ badge.className = `incident-status ${resolved ? 'resolved' : ''}`;
+ }
+}
+
+form.addEventListener('change', () => {
+ description.textContent = scenarioDetails.get(form.value)?.description ?? '';
+});
+
+button.addEventListener('click', async () => {
+ button.disabled = true;
+ button.querySelector('#run-icon').textContent = '…';
+ button.querySelector('#run-label').textContent = 'Running scenario';
+ setRunState('Checks in progress', 'running');
+ document.querySelector('#incident-panel').hidden = true;
+ try {
+ const response = await fetch('/api/demo/run', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ scenario: form.value })
+ });
+ const payload = await response.json();
+ if (!response.ok) throw new Error(payload.error ?? 'The demo could not run');
+ render(payload);
+ } catch (error) {
+ setRunState('Could not run', '');
+ const list = document.querySelector('#activity-list');
+ const message = element('p', 'error-message', error.message);
+ list.replaceChildren(message);
+ document.querySelector('#activity-footer').textContent = 'Try again in a moment.';
+ } finally {
+ button.disabled = false;
+ button.querySelector('#run-icon').textContent = '▶';
+ button.querySelector('#run-label').textContent = 'Run again';
+ }
+});
+
+try {
+ const response = await fetch('/api/demo/scenarios');
+ if (!response.ok) throw new Error('Demo scenarios are unavailable');
+ const payload = await response.json();
+ form.replaceChildren();
+ for (const scenario of payload.scenarios) {
+ scenarioDetails.set(scenario.id, scenario);
+ form.append(new Option(scenario.title, scenario.id));
+ }
+ form.disabled = false;
+ description.textContent = scenarioDetails.get(form.value)?.description ?? '';
+ button.disabled = false;
+} catch (error) {
+ form.replaceChildren(new Option('Scenarios unavailable', ''));
+ description.textContent = error.message;
+ setRunState('Demo unavailable', '');
+}
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..01958f6
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..794107d
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+ API Health Monitor · Interactive demo
+
+
+
+
+
+ Skip to demo
+
+
+
+
+ API reliability, made visible
+ Know when an API is actually healthy.
+ Try a few controlled scenarios. See how response codes, response times, and the data returned affect a service’s health.
+
+
+
+
+
+
DEMO WORKSPACE
+
Service overview
+
+
● Sample data
+
+
+
+
+ Checkout API ↗
+ Ready to test
+ Run a scenario to check the service
+
+
+ Recent checks ⌁
+ 0
+ in this scenario
+
+
+ Consecutive failures ↗
+ 0
+ Incident opens at 3
+
+
+
+
+
+
+
+
CONTROLLED SCENARIOS
+
What should happen?
+
+
01—04
+
+ Choose a scenario
+
+ Loading scenarios…
+
+ Loading the demo scenarios.
+
+ ▶
+ Run scenario
+ →
+
+
+
+
i
+
Health rule HTTP 200, response under 120 ms, and data.ready must be true.
+
+
+
+
+
+
+
CHECK HISTORY
+
Backend activity
+
+
Waiting for a run
+
+
+
+
+
⌁
+
Nothing recorded yet.
+
Choose a scenario to see the checks and incident history.
+
+
+
+
!
+
+ Incident opened
+ Three checks failed in a row.
+
+
Open
+
+
+
+
+
+
+
+
+
+
+
WHAT THIS DEMO SHOWS
+
A green status code doesn’t tell the whole story.
+
+ An API can answer successfully and still return data your app can’t use. Pulsecheck checks what came back, tracks repeated failures, and records when service recovers.
+ 3 failures open an incident
+
+
+
+
+
diff --git a/public/styles.css b/public/styles.css
new file mode 100644
index 0000000..e757029
--- /dev/null
+++ b/public/styles.css
@@ -0,0 +1,180 @@
+:root {
+ color-scheme: light;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ color: #172436;
+ background: #f5f7fb;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ --muted: #748198;
+ --border: #e6eaf1;
+ --green: #128266;
+ --red: #c84a4a;
+}
+
+* { box-sizing: border-box; }
+
+body { margin: 0; min-width: 320px; }
+
+button, select { font: inherit; }
+
+.skip-link { position: absolute; left: -9999px; top: 8px; z-index: 2; padding: 10px 14px; background: #fff; color: #172436; }
+.skip-link:focus { left: 8px; }
+
+.topbar { height: 68px; display: flex; align-items: center; justify-content: space-between; padding: 0 max(32px, calc((100% - 1180px) / 2)); border-bottom: 1px solid #e9edf3; background: rgba(255,255,255,.88); }
+.wordmark { display: flex; align-items: center; gap: 10px; color: #172436; font-size: 15px; font-weight: 720; letter-spacing: -.35px; text-decoration: none; }
+.brand-mark { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; background: #e6f5f1; color: #138269; }
+.brand-mark svg { width: 22px; height: 22px; }
+.topbar-meta { display: flex; align-items: center; gap: 10px; color: #57657a; font-size: 12px; }
+.demo-dot, .state-dot { width: 7px; height: 7px; border-radius: 50%; background: #29a17d; box-shadow: 0 0 0 3px #e4f5ef; }
+.topbar-divider { height: 15px; width: 1px; margin: 0 2px; background: #e2e7ef; }
+.topbar-note { color: #8994a6; }
+.repo-link { color: #384860; font-size: 12px; font-weight: 650; text-decoration: none; }
+.repo-link:hover { color: #138269; }
+.repo-link span { margin-left: 3px; }
+
+.page-shell { max-width: 1180px; margin: 0 auto; padding: 54px 32px 28px; }
+.intro { margin-bottom: 35px; }
+.eyebrow, .section-label { display: flex; align-items: center; gap: 8px; color: #758198; font-size: 10px; font-weight: 720; letter-spacing: 1.15px; }
+.eyebrow-line { width: 19px; height: 2px; background: #1d9a7a; }
+.intro h1 { margin: 13px 0 11px; color: #18263a; font-size: clamp(33px, 4vw, 47px); font-weight: 660; letter-spacing: -2px; line-height: 1.11; }
+.intro p { max-width: 590px; margin: 0; color: #78859a; font-size: 14px; line-height: 1.7; }
+
+.workspace { overflow: hidden; border: 1px solid #e3e8f0; border-radius: 13px; background: #fff; box-shadow: 0 9px 30px rgba(34,52,82,.035); }
+.workspace-heading { display: flex; align-items: center; justify-content: space-between; padding: 21px 24px 17px; }
+.workspace-heading h2, .panel-heading h2 { margin: 6px 0 0; color: #243249; font-size: 15px; font-weight: 660; letter-spacing: -.25px; }
+.section-label { color: #8591a4; font-size: 9px; letter-spacing: 1.05px; }
+.sample-badge { display: inline-flex; align-items: center; gap: 7px; padding: 6px 9px; border: 1px solid #e8edf3; border-radius: 6px; color: #77859a; font-size: 10px; }
+.sample-badge span { color: #319a7b; font-size: 8px; }
+
+.summary-grid { display: grid; grid-template-columns: 1.25fr 1fr 1fr; gap: 11px; padding: 0 24px 19px; }
+.summary-card { min-height: 103px; padding: 15px 16px 13px; border: 1px solid #e8ecf2; border-radius: 8px; background: #fff; }
+.status-summary { background: #fbfcfe; }
+.summary-top { display: flex; align-items: center; justify-content: space-between; color: #7c889c; font-size: 11px; }
+.service-icon, .mini-icon { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 6px; color: #628174; background: #edf6f2; font-size: 12px; }
+.mini-icon { color: #818da0; background: #f2f4f8; }
+.status-value { display: flex; align-items: center; gap: 8px; margin-top: 11px; color: #747f91; font-size: 16px; font-weight: 690; letter-spacing: -.35px; }
+.status-value::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #acb5c2; }
+.status-value.up { color: #147a62; }
+.status-value.up::before { background: #199575; box-shadow: 0 0 0 3px #e3f4ee; }
+.status-value.down { color: #bd4242; }
+.status-value.down::before { background: #d65353; box-shadow: 0 0 0 3px #fae9e9; }
+.metric-value { margin-top: 6px; color: #29384d; font-size: 23px; font-weight: 650; letter-spacing: -.8px; }
+.summary-caption { margin-top: 4px; color: #96a0b0; font-size: 10px; }
+
+.main-grid { display: grid; grid-template-columns: minmax(265px, .78fr) minmax(0, 1.55fr); gap: 13px; padding: 0 24px 20px; }
+.panel { min-width: 0; border: 1px solid #e8ecf2; border-radius: 8px; }
+.scenario-panel { padding: 18px; background: #fbfcfe; }
+.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
+.panel-heading h2 { margin-top: 5px; font-size: 14px; }
+.step-count { padding-top: 4px; color: #a1aabb; font-size: 10px; letter-spacing: .5px; }
+.field-label { display: block; margin: 24px 0 7px; color: #626f84; font-size: 11px; font-weight: 620; }
+.scenario-select { width: 100%; min-height: 39px; padding: 0 34px 0 11px; border: 1px solid #dfe5ee; border-radius: 6px; outline: none; background: #fff; color: #35445b; font-size: 11px; }
+.scenario-select:focus-visible, .run-button:focus-visible, .repo-link:focus-visible { outline: 3px solid rgba(26,144,116,.27); outline-offset: 2px; }
+.scenario-description { min-height: 38px; margin: 9px 0 13px; color: #808ca0; font-size: 11px; line-height: 1.55; }
+.run-button { display: flex; align-items: center; justify-content: center; gap: 9px; width: 100%; min-height: 40px; border: 0; border-radius: 6px; background: #138168; color: #fff; cursor: pointer; font-size: 11px; font-weight: 680; transition: background .15s ease, transform .15s ease; }
+.run-button:hover:not(:disabled) { transform: translateY(-1px); background: #0e705a; }
+.run-button:disabled { cursor: wait; opacity: .64; }
+.button-arrow { margin-left: auto; padding-right: 4px; font-size: 16px; font-weight: 400; }
+.run-button #run-icon { font-size: 9px; }
+.scenario-footnote { margin: 10px 0 0; color: #969faf; font-size: 10px; text-align: center; }
+.rule-note { display: flex; gap: 9px; margin-top: 19px; padding: 11px; border: 1px solid #ebeff4; border-radius: 6px; background: #fff; }
+.rule-icon { flex: 0 0 16px; display: grid; place-items: center; width: 16px; height: 16px; border-radius: 50%; background: #edf4f2; color: #467f70; font-size: 10px; font-weight: 700; }
+.rule-note p { margin: 0; color: #858fa0; font-size: 10px; line-height: 1.6; }
+.rule-note strong { color: #536178; font-weight: 660; }
+.rule-note code { padding: 1px 3px; border-radius: 3px; background: #f1f4f8; color: #4f5d70; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 9px; }
+
+.activity-panel { display: flex; flex-direction: column; min-height: 340px; padding: 18px 18px 0; }
+.activity-heading { min-height: 38px; }
+.run-state { display: inline-flex; align-items: center; gap: 7px; padding: 5px 8px; border-radius: 5px; background: #f3f5f8; color: #8490a2; font-size: 9px; white-space: nowrap; }
+.run-state .state-dot { width: 6px; height: 6px; box-shadow: none; background: #aab4c2; }
+.run-state.running .state-dot { background: #dc9b34; animation: pulse 1s ease infinite; }
+.run-state.finished .state-dot { background: #269575; }
+.log-header, .activity-row { display: grid; grid-template-columns: minmax(160px, 1fr) 118px 67px; align-items: center; gap: 10px; }
+.log-header { margin-top: 18px; padding: 0 8px 8px; border-bottom: 1px solid #edf0f4; color: #99a3b2; font-size: 8px; font-weight: 700; letter-spacing: .8px; }
+.log-header span:nth-child(2), .log-header span:nth-child(3) { text-align: right; }
+.activity-list { flex: 1; min-height: 157px; }
+.empty-state { display: flex; min-height: 157px; flex-direction: column; align-items: center; justify-content: center; color: #9aa4b2; text-align: center; }
+.empty-icon { display: grid; place-items: center; width: 33px; height: 33px; margin-bottom: 9px; border-radius: 9px; background: #f3f6f8; color: #78889b; font-size: 20px; }
+.empty-state p { margin: 0 0 4px; color: #6b788d; font-size: 11px; font-weight: 630; }
+.empty-state > span:last-child { max-width: 240px; font-size: 10px; line-height: 1.5; }
+.activity-row { min-height: 43px; padding: 0 8px; border-bottom: 1px solid #f0f2f6; animation: arrive .24s ease both; }
+.activity-row:last-child { border-bottom: 0; }
+.check-name { display: flex; align-items: center; min-width: 0; gap: 8px; color: #4e5c71; font-size: 10px; }
+.check-index { flex: 0 0 20px; display: grid; place-items: center; width: 20px; height: 20px; border: 1px solid #e8ecf1; border-radius: 5px; color: #8c98aa; font-size: 8px; font-weight: 650; }
+.check-name span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.check-result { justify-self: end; min-width: 75px; padding: 5px 7px; border-radius: 4px; background: #eaf6f1; color: #278267; font-size: 9px; font-weight: 650; text-align: center; }
+.check-result.failed { background: #fff0ee; color: #bc5349; }
+.check-result.slow { background: #fff6e6; color: #a6721a; }
+.check-time { color: #748196; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 9px; text-align: right; }
+.incident-panel { display: flex; align-items: center; gap: 10px; margin: 10px 0 0; padding: 10px; border: 1px solid #f1dbd7; border-radius: 6px; background: #fff9f8; }
+.incident-panel[hidden] { display: none; }
+.incident-icon { display: grid; flex: 0 0 22px; place-items: center; width: 22px; height: 22px; border-radius: 50%; background: #f8e9e6; color: #bf5349; font-size: 13px; font-weight: 750; }
+.incident-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 2px; }
+.incident-copy strong { color: #7f3d36; font-size: 10px; font-weight: 660; }
+.incident-copy span { overflow: hidden; color: #a17c76; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
+.incident-status { padding: 4px 6px; border-radius: 4px; background: #fae9e6; color: #aa483e; font-size: 8px; font-weight: 680; }
+.incident-status.resolved { background: #e7f5ef; color: #237c63; }
+.activity-footer { margin: 0 -18px; padding: 9px 18px; border-top: 1px solid #edf0f4; color: #98a2b1; font-size: 9px; }
+.workspace-footer { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 11px 24px; border-top: 1px solid #edf0f4; color: #8b96a7; font-size: 9px; }
+.footer-lock { margin-right: 5px; color: #458b77; }
+
+.how-it-works { display: grid; grid-template-columns: 1.2fr 1.3fr 120px; align-items: center; gap: 44px; padding: 34px 14px 31px; }
+.how-it-works h2 { margin: 10px 0 0; color: #26364b; font-size: 19px; font-weight: 630; letter-spacing: -.55px; line-height: 1.35; }
+.how-it-works > p { margin: 0; color: #7d899d; font-size: 11px; line-height: 1.75; }
+.how-stat { display: flex; align-items: center; justify-content: flex-end; gap: 10px; color: #778398; font-size: 10px; line-height: 1.45; }
+.how-stat strong { color: #188068; font-size: 34px; font-weight: 600; letter-spacing: -1.3px; }
+.page-footer { display: flex; justify-content: space-between; gap: 12px; padding-top: 16px; border-top: 1px solid #e8ecf2; color: #a0a9b7; font-size: 9px; }
+.error-message { margin: 13px 0 0; color: #bd4242; font-size: 11px; line-height: 1.5; }
+
+@keyframes arrive { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
+@keyframes pulse { 50% { opacity: .35; } }
+
+@media (max-width: 780px) {
+ .topbar { padding: 0 22px; }
+ .topbar-note, .topbar-divider { display: none; }
+ .page-shell { padding: 42px 22px 24px; }
+ .main-grid { grid-template-columns: minmax(225px, .85fr) minmax(0, 1.15fr); padding-right: 16px; padding-left: 16px; }
+ .summary-grid { padding-right: 16px; padding-left: 16px; }
+ .workspace-heading { padding-right: 16px; padding-left: 16px; }
+ .activity-panel { padding-right: 12px; padding-left: 12px; }
+ .activity-footer { margin-right: -12px; margin-left: -12px; padding-right: 12px; padding-left: 12px; }
+ .log-header, .activity-row { grid-template-columns: minmax(100px, 1fr) 90px 52px; gap: 6px; }
+ .check-result { min-width: 66px; }
+ .how-it-works { gap: 22px; }
+}
+
+@media (max-width: 590px) {
+ .topbar { height: 58px; padding: 0 15px; }
+ .wordmark { font-size: 13px; }
+ .brand-mark { width: 27px; height: 27px; }
+ .topbar-meta { gap: 7px; font-size: 10px; }
+ .repo-link { font-size: 10px; }
+ .page-shell { padding: 32px 14px 20px; }
+ .intro { margin-bottom: 24px; }
+ .intro h1 { font-size: 35px; letter-spacing: -1.5px; }
+ .intro p { font-size: 12px; }
+ .desktop-break { display: none; }
+ .summary-grid { grid-template-columns: 1fr 1fr; gap: 8px; padding: 0 12px 13px; }
+ .summary-card { min-height: 90px; padding: 12px; }
+ .status-summary { grid-column: 1 / -1; }
+ .workspace-heading { padding: 16px 12px 14px; }
+ .main-grid { grid-template-columns: 1fr; gap: 10px; padding: 0 12px 14px; }
+ .scenario-panel { padding: 15px; }
+ .activity-panel { min-height: 310px; padding: 15px 12px 0; }
+ .activity-footer { margin-right: -12px; margin-left: -12px; padding-right: 12px; padding-left: 12px; }
+ .log-header, .activity-row { grid-template-columns: minmax(100px, 1fr) 83px 48px; gap: 5px; }
+ .check-name { gap: 6px; font-size: 9px; }
+ .check-result { min-width: 62px; padding: 5px 4px; font-size: 8px; }
+ .workspace-footer { align-items: flex-start; padding: 10px 12px; font-size: 8px; }
+ .workspace-footer span:last-child { white-space: nowrap; }
+ .how-it-works { grid-template-columns: 1fr auto; gap: 14px 20px; padding: 26px 3px; }
+ .how-it-works > p { grid-column: 1 / -1; grid-row: 2; }
+ .how-stat { grid-column: 2; grid-row: 1; }
+ .how-stat strong { font-size: 28px; }
+ .page-footer { flex-direction: column; gap: 5px; font-size: 8px; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
+}
diff --git a/render.yaml b/render.yaml
new file mode 100644
index 0000000..917c36a
--- /dev/null
+++ b/render.yaml
@@ -0,0 +1,19 @@
+services:
+ - type: web
+ name: api-health-monitor-demo
+ runtime: node
+ plan: free
+ branch: demo-dashboard
+ buildCommand: npm ci
+ startCommand: npm start
+ autoDeployTrigger: 'off'
+ healthCheckPath: /api/demo/scenarios
+ envVars:
+ - key: NODE_VERSION
+ value: '24'
+ - key: NODE_ENV
+ value: production
+ - key: HOST
+ value: 0.0.0.0
+ - key: PUBLIC_DEMO
+ value: 'true'
diff --git a/src/app.js b/src/app.js
index 797f5db..26734eb 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1,11 +1,40 @@
import express from 'express';
+import { fileURLToPath } from 'node:url';
import { historyLimit, validateMonitor } from './validation.js';
+import { HttpError } from './errors.js';
-export function createApp({ store, runner } = {}) {
+const publicDirectory = fileURLToPath(new URL('../public/', import.meta.url));
+const localHosts = ['localhost', '127.0.0.1', '[::1]'];
+const demoFiles = new Set(['/', '/index.html', '/styles.css', '/app.js', '/favicon.svg']);
+
+export function createApp({ store, runner, demo, publicDemo = false, managementEnabled = true } = {}) {
const app = express();
app.disable('x-powered-by');
app.use((req, res, next) => {
- if (!['localhost', '127.0.0.1', '[::1]'].includes(req.hostname)) {
+ res.set('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'");
+ res.set('X-Content-Type-Options', 'nosniff');
+ res.set('Referrer-Policy', 'no-referrer');
+ next();
+ });
+ app.use((req, res, next) => {
+ const isDemoFile = publicDemo && ['GET', 'HEAD'].includes(req.method) && demoFiles.has(req.path);
+ const isDemoApi = publicDemo && ['/api/demo/scenarios', '/api/demo/run'].includes(req.path);
+ if (isDemoFile) return next();
+ if (isDemoApi) {
+ const origin = req.get('origin');
+ if (origin) {
+ try {
+ if (!['http:', 'https:'].includes(new URL(origin).protocol) || new URL(origin).host !== req.get('host')) {
+ return res.status(403).json({ error: 'Cross-origin requests are not accepted' });
+ }
+ } catch {
+ return res.status(403).json({ error: 'Cross-origin requests are not accepted' });
+ }
+ }
+ res.set('Cache-Control', 'no-store');
+ return next();
+ }
+ if (!managementEnabled || !localHosts.includes(req.hostname)) {
return res.status(403).json({ error: 'Only local requests are accepted' });
}
if (req.headers.origin && req.headers.origin !== `http://${req.headers.host}`) {
@@ -15,6 +44,21 @@ export function createApp({ store, runner } = {}) {
});
app.use(express.json({ limit: '16kb' }));
+ if (publicDemo) app.use(express.static(publicDirectory, { index: 'index.html', maxAge: '1h' }));
+
+ if (publicDemo && demo) {
+ app.get('/api/demo/scenarios', (req, res) => {
+ res.json({ monitor: 'Checkout API', scenarios: demo.scenarios() });
+ });
+
+ app.post('/api/demo/run', async (req, res) => {
+ if (!req.body || Array.isArray(req.body) || Object.keys(req.body).length !== 1 || typeof req.body.scenario !== 'string') {
+ throw new HttpError(400, 'Body must contain one scenario ID');
+ }
+ res.json(await demo.run(req.body.scenario));
+ });
+ }
+
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
diff --git a/src/demo-service.js b/src/demo-service.js
new file mode 100644
index 0000000..cbf2aa6
--- /dev/null
+++ b/src/demo-service.js
@@ -0,0 +1,124 @@
+import { createChecker } from './checker.js';
+import { HttpError } from './errors.js';
+import { Runner } from './runner.js';
+import { Store } from './store.js';
+import { validateMonitor } from './validation.js';
+
+const scenarios = [
+ {
+ id: 'recovery',
+ title: 'Outage and recovery',
+ description: 'See an incident open after three failed checks, then resolve when the API recovers.',
+ steps: [
+ { label: 'API returns HTTP 503', response: { statusCode: 503, body: '{"error":"temporarily unavailable"}' } },
+ { label: 'API returns HTTP 200 with the wrong value', response: { statusCode: 200, body: '{"data":{"ready":false}}' } },
+ { label: 'API times out', delayMs: 1200 },
+ { label: 'API recovers', response: { statusCode: 200, body: '{"data":{"ready":true}}' } }
+ ]
+ },
+ {
+ id: 'wrong-json',
+ title: 'HTTP 200, wrong JSON',
+ description: 'The server answers, but its readiness field says the service is not ready.',
+ steps: [
+ { label: 'Check the readiness field', response: { statusCode: 200, body: '{"data":{"ready":false}}' } }
+ ]
+ },
+ {
+ id: 'slow-response',
+ title: 'Slow response',
+ description: 'The API returns the right value after the configured response limit.',
+ steps: [
+ { label: 'Check the response time', delayMs: 250, response: { statusCode: 200, body: '{"data":{"ready":true}}' } }
+ ]
+ },
+ {
+ id: 'healthy',
+ title: 'Healthy API',
+ description: 'A normal check passes both the status and JSON rules.',
+ steps: [
+ { label: 'Check the API', response: { statusCode: 200, body: '{"data":{"ready":true}}' } }
+ ]
+ }
+];
+
+function waitForResponse(step, signal) {
+ return new Promise((resolve, reject) => {
+ if (signal.aborted) return reject(signal.reason);
+ const timer = setTimeout(() => {
+ signal.removeEventListener('abort', abort);
+ resolve(step.response);
+ }, step.delayMs);
+ function abort() {
+ clearTimeout(timer);
+ reject(signal.reason);
+ }
+ signal.addEventListener('abort', abort, { once: true });
+ });
+}
+
+function publicMonitor(monitor) {
+ return {
+ name: monitor.name,
+ status: monitor.status,
+ consecutiveFailures: monitor.consecutiveFailures,
+ lastCheckedAt: monitor.lastCheckedAt
+ };
+}
+
+export function createDemoService({ maxConcurrentRuns = 4 } = {}) {
+ let activeRuns = 0;
+
+ async function run(scenarioId) {
+ const scenario = scenarios.find(item => item.id === scenarioId);
+ if (!scenario) throw new HttpError(400, 'Choose a listed demo scenario');
+ if (activeRuns >= maxConcurrentRuns) throw new HttpError(429, 'The demo is busy; try again in a moment');
+ activeRuns++;
+ const store = new Store();
+ let step;
+ const config = validateMonitor({
+ name: 'Checkout API',
+ url: 'https://checkout.example.com/health',
+ intervalSeconds: 10,
+ timeoutMs: 450,
+ maxResponseMs: 120,
+ expectedJson: { path: 'data.ready', equals: true }
+ });
+ const monitor = store.create(config);
+ const checker = createChecker({
+ lookup: async () => [{ address: '93.184.215.14', family: 4 }],
+ request: async (url, target, signal) => step.delayMs
+ ? waitForResponse(step, signal)
+ : step.response
+ });
+ const runner = new Runner(store, checker, { concurrency: 1 });
+
+ try {
+ const checks = [];
+ for (step of scenario.steps) {
+ const result = await runner.run(monitor.id);
+ checks.push({
+ label: step.label,
+ ...result,
+ status: store.get(monitor.id).status,
+ consecutiveFailures: store.get(monitor.id).consecutiveFailures
+ });
+ }
+ return {
+ scenario: { id: scenario.id, title: scenario.title },
+ monitor: publicMonitor(store.get(monitor.id)),
+ checks,
+ incidents: store.incidents(monitor.id, 100)
+ };
+ } finally {
+ await runner.stop();
+ store.close();
+ activeRuns--;
+ }
+ }
+
+ return {
+ scenarios: () => scenarios.map(({ id, title, description }) => ({ id, title, description })),
+ run
+ };
+}
diff --git a/src/server.js b/src/server.js
index f0d9971..5f652e8 100644
--- a/src/server.js
+++ b/src/server.js
@@ -2,6 +2,7 @@ import { createApp } from './app.js';
import { Store } from './store.js';
import { createChecker } from './checker.js';
import { Runner } from './runner.js';
+import { createDemoService } from './demo-service.js';
const port = Number(process.env.PORT ?? 3000);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
@@ -10,9 +11,13 @@ if (!Number.isInteger(port) || port < 1 || port > 65535) {
const store = new Store(process.env.DB_PATH ?? 'data/monitor.db');
const runner = new Runner(store, createChecker());
-const server = createApp({ store, runner }).listen(port, '127.0.0.1', () => {
- runner.start();
- console.log(`API Health Monitor listening at http://localhost:${port}`);
+const host = process.env.HOST ?? '127.0.0.1';
+const isLoopbackHost = ['127.0.0.1', 'localhost', '::1'].includes(host);
+const publicDemo = process.env.PUBLIC_DEMO === 'true' ||
+ (process.env.PUBLIC_DEMO !== 'false' && isLoopbackHost);
+const server = createApp({ store, runner, demo: createDemoService(), publicDemo, managementEnabled: isLoopbackHost }).listen(port, host, () => {
+ if (isLoopbackHost) runner.start();
+ console.log(`API Health Monitor listening at http://${host}:${port}`);
});
server.requestTimeout = 10000;
server.headersTimeout = 10000;
diff --git a/test/dashboard.test.js b/test/dashboard.test.js
new file mode 100644
index 0000000..32f27d8
--- /dev/null
+++ b/test/dashboard.test.js
@@ -0,0 +1,33 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { Store } from '../src/store.js';
+import { Runner } from '../src/runner.js';
+import { createDemoService } from '../src/demo-service.js';
+import { createApp } from '../src/app.js';
+import { serve } from './helpers.js';
+
+test('serves a self-contained dashboard and browser assets with a restrictive content policy', async t => {
+ const store = new Store();
+ const runner = new Runner(store, async () => ({}));
+ const base = await serve(t, createApp({ store, runner, demo: createDemoService(), publicDemo: true }));
+ t.after(() => store.close());
+
+ const page = await fetch(base);
+ const html = await page.text();
+ assert.equal(page.status, 200);
+ assert.match(page.headers.get('content-type'), /text\/html/);
+ assert.match(page.headers.get('content-security-policy'), /default-src 'self'/);
+ assert.match(page.headers.get('content-security-policy'), /connect-src 'self'/);
+ assert.match(html, /Know when an API is/);
+ assert.match(html, /Run scenario/);
+ assert.match(html, /\/styles\.css/);
+ assert.match(html, /\/app\.js/);
+
+ const styles = await fetch(`${base}/styles.css`);
+ assert.equal(styles.status, 200);
+ assert.match(styles.headers.get('content-type'), /text\/css/);
+ const script = await fetch(`${base}/app.js`);
+ assert.equal(script.status, 200);
+ assert.match(script.headers.get('content-type'), /javascript/);
+ assert.match(await script.text(), /\/api\/demo\/run/);
+});
diff --git a/test/demo-api.test.js b/test/demo-api.test.js
new file mode 100644
index 0000000..0043835
--- /dev/null
+++ b/test/demo-api.test.js
@@ -0,0 +1,67 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import http from 'node:http';
+import { Store } from '../src/store.js';
+import { Runner } from '../src/runner.js';
+import { createDemoService } from '../src/demo-service.js';
+import { createApp } from '../src/app.js';
+import { serve, jsonRequest } from './helpers.js';
+
+test('browser demo lists presets and runs only a selected fixed scenario', async t => {
+ const store = new Store();
+ const runner = new Runner(store, async () => { throw new Error('real monitor checker must not run'); });
+ const base = await serve(t, createApp({ store, runner, demo: createDemoService(), publicDemo: true }));
+ t.after(() => store.close());
+ const scenarios = await jsonRequest(base, '/api/demo/scenarios');
+ assert.equal(scenarios.status, 200);
+ assert.equal(scenarios.body.scenarios.length, 4);
+ const wrong = await jsonRequest(base, '/api/demo/run', 'POST', { scenario: 'wrong-json' });
+ assert.equal(wrong.body.checks[0].statusCode, 200);
+ assert.equal(wrong.body.checks[0].failureCode, 'json_mismatch');
+ assert.equal(store.list().length, 0);
+});
+
+test('demo API rejects bad inputs, foreign origins and remote monitor access', async t => {
+ const store = new Store();
+ const runner = new Runner(store, async () => ({}));
+ const base = await serve(t, createApp({ store, runner, demo: createDemoService(), publicDemo: true }));
+ t.after(() => store.close());
+ assert.equal((await jsonRequest(base, '/api/demo/run', 'POST', { scenario: 'https://example.org' })).status, 400);
+ assert.equal((await jsonRequest(base, '/api/demo/run', 'POST', { scenario: 'healthy', url: 'http://127.0.0.1' })).status, 400);
+ assert.equal((await fetch(`${base}/api/demo/run`, {
+ method: 'POST', headers: { 'content-type': 'application/json', origin: 'https://attacker.example' },
+ body: JSON.stringify({ scenario: 'healthy' })
+ })).status, 403);
+ const status = await new Promise((resolve, reject) => {
+ http.get(`${base}/monitors`, { headers: { host: 'public.example' } }, response => {
+ response.resume();
+ resolve(response.statusCode);
+ }).on('error', reject);
+ });
+ assert.equal(status, 403);
+});
+
+test('static dashboard and public demo API can be disabled', async t => {
+ const store = new Store();
+ const runner = new Runner(store, async () => ({}));
+ const base = await serve(t, createApp({ store, runner, demo: createDemoService() }));
+ t.after(() => store.close());
+ assert.equal((await fetch(base)).status, 404);
+ assert.equal((await fetch(`${base}/api/demo/scenarios`)).status, 404);
+});
+
+test('hosted demo blocks management even when the Host header claims localhost', async t => {
+ const base = await serve(t, createApp({ demo: createDemoService(), publicDemo: true, managementEnabled: false }));
+ for (const host of ['localhost', '127.0.0.1', '[::1]', 'public.example']) {
+ for (const path of ['/monitors', '/monitors/1/check', '/health']) {
+ const status = await new Promise((resolve, reject) => {
+ http.get(`${base}${path}`, { headers: { host } }, response => {
+ response.resume();
+ resolve(response.statusCode);
+ }).on('error', reject);
+ });
+ assert.equal(status, 403);
+ }
+ }
+ assert.equal((await jsonRequest(base, '/api/demo/scenarios')).status, 200);
+});
diff --git a/test/demo-service.test.js b/test/demo-service.test.js
new file mode 100644
index 0000000..2f56ee1
--- /dev/null
+++ b/test/demo-service.test.js
@@ -0,0 +1,44 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createDemoService } from '../src/demo-service.js';
+
+test('recovery scenario records a failure sequence, opens an incident and resolves it', async () => {
+ const demo = createDemoService();
+ const result = await demo.run('recovery');
+ assert.deepEqual(result.checks.map(check => check.failureCode), [
+ 'status_mismatch', 'json_mismatch', 'timeout', null
+ ]);
+ assert.deepEqual(result.checks.map(check => check.status), ['pending', 'pending', 'down', 'up']);
+ assert.deepEqual(result.checks.map(check => check.consecutiveFailures), [1, 2, 3, 0]);
+ assert.equal(result.incidents.length, 1);
+ assert.equal(result.incidents[0].resolution, 'recovered');
+ assert.equal(result.monitor.name, 'Checkout API');
+ assert.equal(result.monitor.status, 'up');
+});
+
+test('single check scenarios demonstrate JSON validation, response time and success', async () => {
+ const demo = createDemoService();
+ const wrongJson = await demo.run('wrong-json');
+ assert.equal(wrongJson.checks[0].statusCode, 200);
+ assert.equal(wrongJson.checks[0].failureCode, 'json_mismatch');
+ const slow = await demo.run('slow-response');
+ assert.equal(slow.checks[0].failureCode, 'slow_response');
+ assert.ok(slow.checks[0].durationMs > 120);
+ const healthy = await demo.run('healthy');
+ assert.equal(healthy.checks[0].ok, true);
+ assert.equal(healthy.monitor.status, 'up');
+});
+
+test('unknown scenario IDs are rejected', async () => {
+ await assert.rejects(createDemoService().run('https://attacker.example'), {
+ status: 400,
+ message: 'Choose a listed demo scenario'
+ });
+});
+
+test('simultaneous runs are bounded', async () => {
+ const demo = createDemoService({ maxConcurrentRuns: 1 });
+ const first = demo.run('recovery');
+ await assert.rejects(demo.run('healthy'), { status: 429 });
+ assert.equal((await first).monitor.status, 'up');
+});
diff --git a/test/server.test.js b/test/server.test.js
index 17c7811..1484a9c 100644
--- a/test/server.test.js
+++ b/test/server.test.js
@@ -17,9 +17,9 @@ test('server starts, persists a monitor across restart and exits on SIGTERM', {
await new Promise(resolve => portServer.close(resolve));
const base = `http://127.0.0.1:${port}`;
- async function start() {
+ async function start(host = '127.0.0.1') {
const child = spawn(process.execPath, ['src/server.js'], {
- env: { ...process.env, PORT: String(port), DB_PATH: join(dir, 'test.db') },
+ env: { ...process.env, PORT: String(port), DB_PATH: join(dir, 'test.db'), HOST: host, PUBLIC_DEMO: 'true' },
stdio: ['ignore', 'pipe', 'pipe']
});
t.after(() => { if (child.exitCode === null) child.kill('SIGKILL'); });
@@ -38,6 +38,16 @@ test('server starts, persists a monitor across restart and exits on SIGTERM', {
}
const first = await start();
+ const page = await fetch(base);
+ assert.equal(page.status, 200);
+ assert.match(await page.text(), /Interactive demo/);
+ const scenarioResponse = await fetch(`${base}/api/demo/run`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ scenario: 'wrong-json' })
+ });
+ assert.equal(scenarioResponse.status, 200);
+ assert.equal((await scenarioResponse.json()).checks[0].failureCode, 'json_mismatch');
const created = await jsonRequest(base, '/monitors', 'POST', {
name: 'Paused API', url: 'https://example.com/health', enabled: false
});
@@ -46,4 +56,10 @@ test('server starts, persists a monitor across restart and exits on SIGTERM', {
const second = await start();
assert.deepEqual((await jsonRequest(base, `/monitors/${created.body.id}`)).body, created.body);
await stop(second);
+ const hosted = await start('0.0.0.0');
+ assert.equal((await jsonRequest(base, '/api/demo/scenarios')).status, 200);
+ assert.equal((await jsonRequest(base, '/api/demo/run', 'POST', { scenario: 'healthy' })).status, 200);
+ assert.equal((await jsonRequest(base, '/monitors')).status, 403);
+ assert.equal((await jsonRequest(base, `/monitors/${created.body.id}/check`, 'POST')).status, 403);
+ await stop(hosted);
});