Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 12 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.
129 changes: 129 additions & 0 deletions public/app.js
Original file line number Diff line number Diff line change
@@ -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', '');
}
1 change: 1 addition & 0 deletions public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
138 changes: 138 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#f5f7fb">
<meta name="description" content="Explore a live API health monitor demo. Test outages, slow responses, JSON checks, and recovery.">
<title>API Health Monitor · Interactive demo</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/styles.css">
<script src="/app.js" type="module"></script>
</head>
<body>
<a class="skip-link" href="#workspace">Skip to demo</a>
<header class="topbar">
<a class="wordmark" href="/" aria-label="API Health Monitor home">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 32 32" fill="none"><path d="M3 17h6l3-9 6 17 4-12 3 4h4" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
<span>Pulsecheck</span>
</a>
<div class="topbar-meta">
<span class="demo-dot" aria-hidden="true"></span>
<span>Interactive demo</span>
<span class="topbar-divider" aria-hidden="true"></span>
<span class="topbar-note">Sample responses · no outside sites are contacted</span>
</div>
<a class="repo-link" href="https://github.com/choidanny719/API-Health-Monitor" target="_blank" rel="noreferrer">
View source <span aria-hidden="true">↗</span>
</a>
</header>

<main id="workspace" class="page-shell">
<section class="intro">
<div class="eyebrow"><span class="eyebrow-line"></span> API reliability, made visible</div>
<h1>Know when an API is<br class="desktop-break"> actually healthy.</h1>
<p>Try a few controlled scenarios. See how response codes, response times, and the data returned affect a service’s health.</p>
</section>

<section class="workspace" aria-label="Interactive API monitor">
<div class="workspace-heading">
<div>
<div class="section-label">DEMO WORKSPACE</div>
<h2>Service overview</h2>
</div>
<span class="sample-badge"><span aria-hidden="true">●</span> Sample data</span>
</div>

<div class="summary-grid">
<article class="summary-card status-summary">
<div class="summary-top"><span>Checkout API</span><span class="service-icon" aria-hidden="true">↗</span></div>
<div id="service-status" class="status-value pending">Ready to test</div>
<div id="service-subtitle" class="summary-caption">Run a scenario to check the service</div>
</article>
<article class="summary-card">
<div class="summary-top">Recent checks <span class="mini-icon" aria-hidden="true">⌁</span></div>
<div id="check-count" class="metric-value">0</div>
<div class="summary-caption">in this scenario</div>
</article>
<article class="summary-card">
<div class="summary-top">Consecutive failures <span class="mini-icon" aria-hidden="true">↗</span></div>
<div id="failure-count" class="metric-value">0</div>
<div id="failure-caption" class="summary-caption">Incident opens at 3</div>
</article>
</div>

<div class="main-grid">
<section class="panel scenario-panel" aria-labelledby="scenario-heading">
<div class="panel-heading">
<div>
<div class="section-label">CONTROLLED SCENARIOS</div>
<h2 id="scenario-heading">What should happen?</h2>
</div>
<span class="step-count">01—04</span>
</div>
<label for="scenario-select" class="field-label">Choose a scenario</label>
<select id="scenario-select" class="scenario-select" disabled>
<option>Loading scenarios…</option>
</select>
<p id="scenario-description" class="scenario-description">Loading the demo scenarios.</p>
<button id="run-button" class="run-button" type="button" disabled>
<span id="run-icon" aria-hidden="true">▶</span>
<span id="run-label">Run scenario</span>
<span class="button-arrow" aria-hidden="true">→</span>
</button>
<p class="scenario-footnote"><span aria-hidden="true">⏱</span> Each run uses its own in-memory sample data.</p>
<div class="rule-note">
<span class="rule-icon" aria-hidden="true">i</span>
<p><strong>Health rule</strong><br>HTTP 200, response under 120 ms, and <code>data.ready</code> must be <code>true</code>.</p>
</div>
</section>

<section class="panel activity-panel" aria-labelledby="activity-heading">
<div class="panel-heading activity-heading">
<div>
<div class="section-label">CHECK HISTORY</div>
<h2 id="activity-heading">Backend activity</h2>
</div>
<span id="run-state" class="run-state"><span class="state-dot" aria-hidden="true"></span> Waiting for a run</span>
</div>
<div class="log-header" aria-hidden="true"><span>CHECK</span><span>RESULT</span><span>TIME</span></div>
<div id="activity-list" class="activity-list" role="log" aria-live="polite" aria-relevant="additions text">
<div class="empty-state">
<span class="empty-icon" aria-hidden="true">⌁</span>
<p>Nothing recorded yet.</p>
<span>Choose a scenario to see the checks and incident history.</span>
</div>
</div>
<div id="incident-panel" class="incident-panel" hidden>
<div class="incident-icon" aria-hidden="true">!</div>
<div class="incident-copy">
<strong id="incident-title">Incident opened</strong>
<span id="incident-detail">Three checks failed in a row.</span>
</div>
<span id="incident-status" class="incident-status">Open</span>
</div>
<div id="activity-footer" class="activity-footer">Waiting for the first check</div>
</section>
</div>

<div class="workspace-footer">
<span><span class="footer-lock" aria-hidden="true">◈</span> Runs through the same checker and incident rules as the backend.</span>
<span>Node.js · Express · SQLite</span>
</div>
</section>

<section class="how-it-works" aria-labelledby="how-heading">
<div>
<div class="section-label">WHAT THIS DEMO SHOWS</div>
<h2 id="how-heading">A green status code<br>doesn’t tell the whole story.</h2>
</div>
<p>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.</p>
<div class="how-stat"><strong>3</strong><span>failures open<br>an incident</span></div>
</section>
<footer class="page-footer"><span>Pulsecheck is a learning project.</span><span>Scenarios use synthetic responses and do not contact external services.</span></footer>
</main>
</body>
</html>
Loading
Loading