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 + + + + + + +
+ + + Pulsecheck + +
+ + Interactive demo + + Sample responses · no outside sites are contacted +
+ + View source + +
+ +
+
+
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.

+
+ +
+
+
+ +

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
+
+
+ +
+
+
+
+ +

What should happen?

+
+ 01—04 +
+ + +

Loading the demo scenarios.

+ +

Each run uses its own in-memory sample data.

+
+ +

Health rule
HTTP 200, response under 120 ms, and data.ready must be true.

+
+
+ +
+
+
+ +

Backend activity

+
+ Waiting for a run +
+ +
+
+ +

Nothing recorded yet.

+ Choose a scenario to see the checks and incident history. +
+
+ + +
+
+ + +
+ +
+
+ +

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.

+
3failures 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); });