English · Italiano
Self-hosted, containerized monitoring for other people's status pages. It polls the public status pages of the providers you depend on — GitHub, Cloudflare, Anthropic, npm, anything running Atlassian Statuspage — and messages you when one of them changes state.
It exists so a single developer or a small team gets early warning about upstream trouble without keeping five status dashboards open. It notifies on transitions, never on every poll, so a quiet week is a silent week.
Two editions from one codebase: Light (polling and notifications only, no server) and UI (the same engine plus a local dashboard, configured at runtime).
- 1. What it does
- 2. Quick start
- 3. Configuration
- 4. Docker
- 5. Verifying a deployment
- 6. HTTP API
- 7. How it works
- 8. Theming and localisation
- 9. Development
- 10. Roadmap
- 11. Branch layout and merge policy
Every few minutes IsItDown fetches each provider's status page, normalises the answer, compares it with what it saw last time, and sends a message only if something actually changed.
GitHub operational ████████████████████████████ 99.98%
Cloudflare degraded ███████████████▁▁▁▁▁████████ 99.61% ← you get a message
Anthropic operational ████████████████████████████ 99.93%
- No external dependencies at runtime. No database server, no message broker, no cloud account. A JSON file or an embedded SQLite file is enough at this scale.
- Config-driven. Adding a provider never means touching code — an entry in
config.yml(Light) or a dialog in the dashboard (UI). - Idempotent notifications. Only state transitions notify: operational → degraded, degraded → outage, outage → resolved. A restart notifies nothing.
- Provider-agnostic. Most providers run Atlassian Statuspage and need no code at all; anything else gets a small adapter.
- Secrets from the environment only. No token is ever written to a config file, a database, an API response or a log line.
| Light | UI | |
|---|---|---|
| Image | ghcr.io/devmanfre/isitdown:light-latest |
…:ui-latest (built FROM light) |
| Poller · Adapters · Diff Engine · Notifiers | shared | shared |
| Configuration | config.yml, re-read every cycle |
SQLite, edited in the dashboard |
| State store | JSON file, atomic writes | SQLite (also carries history) |
| HTTP server | none | Express on :3000 |
| Uptime history and charts | — | 7/30/90-day views |
| Theme | — | light / dark / system |
| Localisation | notification text | notification text and the whole dashboard |
| Footprint | 264MB image, no listening socket | 267MB — the Light image plus one layer |
Both editions run the same core engine. They differ only in what gets injected into it: where configuration comes from, and where state is kept.
UI edition, without a clone — one file, two commands. The images are
published to GHCR for linux/amd64 and linux/arm64, so this is also the
Raspberry Pi, Unraid, Portainer and Synology path:
curl -O https://raw.githubusercontent.com/DevManfre/isitdown/main/docker-compose.yml
docker compose --profile ui up -d
# then visit http://localhost:3000Everything the UI edition needs is configured in the dashboard, except secrets,
which come from the environment only. Put them in a .env next to the compose
file — it is optional, and read if present:
printf 'TELEGRAM_BOT_TOKEN=...\nTELEGRAM_CHAT_ID=...\n' > .env
docker compose --profile ui up -d # recreates the container with the tokensLight edition — polling and notifications, nothing listening. This one needs
a config.yml to mount, so start from a clone:
git clone https://github.com/DevManfre/isitdown.git && cd isitdown
cp .env.example .env # only fill in the channels you will enable
cp config.example.yml config.yml # edit: providers, interval, channels
docker compose --profile light up -d
docker logs -f isitdown-lightBoth can run at once; they use separate data volumes.
Contributors building from the source tree add --build, which overrides the
pull and builds the image locally instead:
docker compose --profile ui up -d --buildRequires Node 24 for the build as well as the runtime — .nvmrc pins it and
npm install refuses anything older, because the runtime's own SQLite driver and
native TypeScript support are load-bearing at build time too. build:ui
additionally runs Vite to bundle the dashboard; build:light skips that step,
since Light ships no dashboard.
nvm use # or: nvm install 24
npm install
cp config.example.yml config.yml
cp .env.example .env
npm run build:light && node dist/light/index.js # Light
npm run build:ui && node dist/ui/server.js # UI, then open :3000Useful overrides when running locally: CONFIG_PATH, DATA_PATH, DB_PATH,
PORT, LOG_LEVEL (see 3.3).
One file, mounted as a volume, re-read at the start of every cycle — editing
it applies on the next poll with no restart. config.example.yml is the tracked
template; config.yml itself is git-ignored, since it is your provider list.
pollIntervalMinutes: 3 # how often to poll every provider
requestTimeoutSeconds: 8 # per-request timeout
maxRetries: 3 # attempts per provider per cycle, with backoff
failureThreshold: 5 # consecutive failures before a "monitoring degraded" warning
locale: en # language for notification messages: en | it
services:
- name: GitHub
id: github
adapter: statuspage
baseUrl: https://www.githubstatus.com
- name: Cloudflare
id: cloudflare
adapter: statuspage
baseUrl: https://www.cloudflarestatus.com
- name: Anthropic
id: anthropic
adapter: statuspage
baseUrl: https://status.claude.com
notifications:
telegram:
enabled: true
botToken: "${TELEGRAM_BOT_TOKEN}"
chatId: "${TELEGRAM_CHAT_ID}"
webhook:
enabled: false
url: "${WEBHOOK_URL}"| Key | Default | Notes |
|---|---|---|
pollIntervalMinutes |
3 |
1–1440. The real delay carries ±10% jitter. |
requestTimeoutSeconds |
8 |
Per HTTP request, not per cycle. |
maxRetries |
3 |
Attempts per provider per cycle, exponential backoff plus jitter. |
failureThreshold |
5 |
Consecutive failed cycles before one "monitoring degraded" warning. |
locale |
en |
en or it; anything unknown falls back to en. |
services[].id |
— | Required. Lowercase slug; it keys the stored state. |
services[].adapter |
— | Required. statuspage covers every Atlassian-hosted page; rss reads any RSS or Atom incident feed. |
services[].enabled |
true |
false keeps the entry but stops polling it. |
Anything invalid stops the container at boot with the reason and the offending path — a missing file, malformed YAML, a bad base URL, a duplicate service id, an empty service list, or an enabled channel whose secret is unset. A container that started with a half-understood configuration would look healthy while silently not alerting, which is the one failure mode worth being loud about.
The UI edition mounts no config.yml; one on disk would be ignored.
Everything lives in SQLite at /app/data/isitdown.db and is edited from
Settings in the dashboard (or through /config):
- polling interval, request timeout, retries
- the service list — add, edit, remove
- which notification channels are enabled, and which environment variable carries each credential
- theme, dashboard language, notification language
Writes take effect on the next poll cycle, with no restart, because the scheduler re-reads its configuration every pass. A fresh database is seeded with GitHub, Cloudflare and Anthropic so the dashboard is useful immediately; your own list is never overwritten afterwards.
| Variable | Editions | Default | Purpose |
|---|---|---|---|
TELEGRAM_BOT_TOKEN |
both | — | Telegram bot token. Required if the Telegram channel is enabled. |
TELEGRAM_CHAT_ID |
both | — | Target chat. Required with the above. |
WEBHOOK_URL |
both | — | Where the generic webhook POSTs. Required if that channel is enabled. |
LOG_LEVEL |
both | info |
debug · info · warn · error. |
CONFIG_PATH |
Light | /app/config/config.yml |
Where to read config.yml. |
DATA_PATH |
Light | /app/data/state.json |
Where to keep the state file. |
DB_PATH |
UI | /app/data/isitdown.db |
SQLite database. |
PORT |
UI | 3000 |
HTTP port. |
Secrets arrive through env_file at runtime; nothing is baked into an image.
docker history on either image shows no ENV layer carrying a value.
The rule is the same in both editions — the environment is the only place a secret exists — but the mechanics differ.
Light. config.yml holds ${VAR} references, resolved at load. An enabled
channel whose variable is unset is a fatal startup error that names the variable:
config file /app/config/config.yml: the telegram channel is enabled
but TELEGRAM_BOT_TOKEN is not set in the environment
UI. The channels table stores the name of the variable
(botTokenEnv: "TELEGRAM_BOT_TOKEN"), never a value, and the name is resolved at
load. Consequences worth knowing:
- Settings shows the variable name and whether it currently resolves. The name is editable; the value is not, and there is no field in which to type one.
PATCH /config/channels/:idrefuses a request carrying a literal secret, so the database is never offered one in the first place.- No API response, DOM node, log line or error message contains a resolved secret. Tests assert this.
- A channel enabled in the database whose variable is unset is skipped for that cycle with a warning, rather than crashing the dashboard — unlike Light, there is a UI in which an operator can see and fix it.
This is a deliberate departure from the design prototype, which drew editable credential fields.
If https://<domain>/api/v2/summary.json returns JSON with status and
incidents, the provider runs Atlassian Statuspage and needs no code — just an
entry with adapter: statuspage. Verified:
| Provider | baseUrl |
|---|---|
| GitHub | https://www.githubstatus.com |
| Cloudflare | https://www.cloudflarestatus.com |
| Anthropic / Claude | https://status.claude.com |
status.anthropic.com issues a 301 to status.claude.com. The adapter follows
redirects so either works; the canonical host avoids the extra hop.
The provider's own status.indicator maps onto the internal severity model:
| Statuspage indicator | IsItDown status |
|---|---|
none |
operational |
minor |
degraded |
major |
partial_outage |
critical |
major_outage |
| unrecognised | major_outage — never silently downgraded |
| absent | unknown |
An incident is active unless its status is resolved or postmortem.
scheduled_maintenances is ignored: the severity model has no maintenance state.
Note that a provider can report degraded with zero open incidents — Statuspage
derives the indicator from component state too. A degraded status grid alongside an
empty Incidents view is correct, not a bug.
A provider can expose hundreds of components: Cloudflare lists every data centre,
grouped by region (Africa, Asia, Europe, …). Select the components that matter and
tick Report only the selected components — scopeToComponents: true in the Light
edition's config.yml — to narrow the whole provider to that selection:
- an incident the provider attributes only to components outside the selection is dropped, so it neither notifies nor reaches the charts and the timeline;
- the provider's reported status becomes the worst status among the selected
components instead of the page-wide
status.indicator; - an incident attached to no component at all is a page-wide notice and is always reported;
- with nothing selected the flag does nothing: scoping to an empty selection would mean silencing the provider.
Each group header in the picker carries its own checkbox, so a whole region is one click.
A large tail of status pages publish a feed and nothing else. adapter: rss
reads any of them, with no code per provider:
- id: example
name: Example
adapter: rss
baseUrl: https://status.example.com/history.rssbaseUrl is the feed URL — this adapter appends nothing to it.
A feed announces incidents; it never states an overall status, so the status is derived, and the derivation is deliberately pessimistic — an entry that cannot be dated or classified reads as trouble, never as recovery:
| Feed entry | Reading |
|---|---|
| Published in the last 24 hours, no closing word | An open incident |
Says resolved, completed, restored, closed |
Closed, whatever else it says |
| Older than 24 hours | No longer current |
| No date at all | Treated as current |
No guid, id or link |
Dropped: nothing stable to key the incident on |
Severity comes from the provider's own wording — partial → partial outage;
outage, down, offline, unavailable, unreachable → major outage;
anything else the provider bothered to announce → degraded.
Two consequences worth knowing before relying on it: the adapter offers no component listing, because a feed has no components; and its incident history never claims to be complete, because a feed is a window onto a history rather than the history itself.
For a provider on neither Statuspage nor a feed, add an adapter under
src/adapters/.
| Channel | Config key | Required variables |
|---|---|---|
| Telegram Bot API | telegram |
TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID |
| Generic webhook | webhook |
WEBHOOK_URL |
| Desktop (Web Push) | webpush |
none |
Desktop push needs nothing configured: the server generates its own VAPID key pair the first time the channel is used and keeps it in the database, so enabling the channel and pressing "enable on this browser" in Settings is the whole setup. Each browser that is enabled appears in the card's device list and can be removed from there.
The webhook POSTs { change, service, message }, so a consumer can either display
the rendered text or route on the structured fields:
{
"change": {
"kind": "status_change",
"providerId": "cloudflare",
"previousStatus": "operational",
"currentStatus": "major_outage",
"at": "2026-08-19T14:32:07.000Z"
},
"service": { "id": "cloudflare", "name": "Cloudflare", "statusUrl": "https://www.cloudflarestatus.com" },
"message": "🔴 Cloudflare — MAJOR OUTAGE\n\nStatus changed from Operational to Major outage.\nUpdated: 2026-08-19 14:32 UTC\n\nhttps://www.cloudflarestatus.com"
}Desktop (Web Push) is UI edition only, and the browser Push API refuses to register a service worker unless the dashboard is served over localhost or HTTPS. A toast carries the affected provider's own icon, so a stack of them is readable at a glance.
Discord and Slack are webhook-shaped and slot in behind the same Notifier
interface; they are not implemented yet.
One Dockerfile, four stages. builder compiles everything once; light and
ui are the two shipped runtime images; dev exists only for
live development and is never built by
docker compose --profile ui up.
builder node:24-alpine npm ci (incl. devDependencies), tsc, vite build, copy non-TS assets into dist
light node:24-alpine prod deps + dist/{core,adapters,notifiers,light}
VOLUME /app/config /app/data · no EXPOSE · no server
dev FROM builder keeps devDependencies · vite build --watch + node --watch · tagged isitdown:dev only
ui FROM light + dist/ui (dashboard and locales) · EXPOSE 3000
builder now also copies tsconfig.web.json, vite.config.ts and
components.json alongside the server tsconfigs, and npm run build runs tsc,
then Vite, then the asset copy — one RUN layer that both runtime stages below
it share. light and ui are otherwise unchanged: the ui stage still begins
FROM light, so the UI image is the Light image plus a single thin layer — base
image, production dependencies and the whole core engine are shared on disk and
in a registry.
dev, the third stage, is FROM builder rather than FROM light — live
development needs the devDependencies (Vite, React, the test tooling) that
light's production npm ci --omit=dev deliberately drops, so it cannot run from
either shipped image. It is tagged isitdown:dev, never isitdown:ui, and only
docker-compose.dev.yml builds it; a target-less docker build . or
docker compose --profile ui up never touches it.
docker build --target light -t isitdown:light .
docker build --target ui -t isitdown:ui .Measured: 12 of the UI image's 14 layers are byte-identical to the Light image.
Tag releases per edition rather than with a bare latest, which would not say
which edition it is. .github/workflows/release.yml pushes four tags per
release to GHCR, each a multi-arch manifest covering linux/amd64 and
linux/arm64:
ghcr.io/devmanfre/isitdown:light-v1.0.0 ghcr.io/devmanfre/isitdown:light-latest
ghcr.io/devmanfre/isitdown:ui-v1.0.0 ghcr.io/devmanfre/isitdown:ui-latest
Every pushed image carries an SBOM and SLSA provenance, and is signed keylessly
with cosign, so what built it is verifiable rather than merely asserted:
cosign verify ghcr.io/devmanfre/isitdown:ui-latest \
--certificate-identity-regexp '^https://github.com/DevManfre/isitdown/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comSee 9.6 for how a release is cut.
docker compose --profile light up -d # mounts ./config.yml (ro) + a data volume
docker compose --profile ui up -d # data volume only, publishes :3000
docker compose --profile light --profile ui up -d # both
docker compose --profile light --profile ui down # stop; volumes surviveBoth services declare a published image: and pull_policy: missing, so a
plain up pulls from GHCR and the file works with no source tree around it.
Adding --build builds the same target from this Dockerfile instead.
A third file, docker-compose.dev.yml, layers on top of the ui profile and runs
the edition straight from the source tree, rebuilding the dashboard bundle in the
background as files change rather than requiring a fresh image — see
9.3.
| Light | UI | |
|---|---|---|
| Mounts | ./config.yml:/app/config/config.yml:ro, volume on /app/data |
volume on /app/data |
| Ports | none | 3000:3000 |
| Healthcheck | age of state.json — every cycle rewrites it, three intervals without a write is unhealthy |
GET /health |
| Start period | 40s | 20s |
| User | node, unprivileged |
node, unprivileged |
The Light edition has no server to probe, which is why its liveness signal is the freshness of the state file rather than an HTTP response.
Both containers stop cleanly on SIGTERM: the scheduler stops, the in-flight cycle
is awaited, the store is closed, exit 0.
Everything in this section has been run against the built containers. Expected output is shown so a difference is obvious.
The examples pipe JSON through jq for
readability. It is not required — drop the pipe to see the raw body, or use the
runtime you already have:
curl -s localhost:3000/status | jq '.providers[] | {id, overallStatus}' # with jq
curl -s localhost:3000/status | node -e 'process.stdin.toArray().then(c => {
for (const p of JSON.parse(Buffer.concat(c)).providers) console.log(p.id, p.overallStatus);
})' # withoutdocker ps --format "{{.Names}} {{.Status}}"
# isitdown-light Up 2 minutes (healthy)
# isitdown-ui Up 2 minutes (healthy)
docker exec isitdown-light node dist/light/healthcheck.js; echo "exit=$?" # exit=0
docker exec isitdown-ui node dist/ui/healthcheck.js; echo "exit=$?" # exit=0
docker logs -f isitdown-light
# {"level":"info","msg":"isitdown light started",...}
# {"level":"info","msg":"poll cycle finished","providers":3,"failed":0,"changes":0}changes:0 on the first cycle is correct: a first observation is a baseline, not
news.
Confirm the Light edition really runs no server:
docker ps --format "{{.Names}} ports={{.Ports}}" | grep light # ports= is empty
docker exec isitdown-light sh -c "ps -o pid,args" # only node dist/light/index.jsConfirm nothing secret was baked into an image:
docker history isitdown:ui --no-trunc --format "{{.CreatedBy}}" | grep -iE "TOKEN=|SECRET="
# no outputOpen http://localhost:3000 and walk the rail: Overview · Providers · Incidents · History · Settings. Then check the two runtime controls in the header:
- the theme button cycles light → dark → system and survives a reload;
- the EN / IT switch changes every string with no page reload, including the
time format (
7:36 PMvs19:36) and decimal separator (99.87%vs99,87%).
The same data over HTTP:
curl -s localhost:3000/status | jq '.providers[] | {id, overallStatus, uptime90}'
curl -s localhost:3000/history?days=7 | jq '{aggregateUptime, months}'
curl -s localhost:3000/config | jq '.channels' # variable names only, never values
curl -s -X POST localhost:3000/poll # force a cycle nowAdd a provider and confirm it is picked up on the next cycle without a restart:
curl -s -X POST localhost:3000/config/services -H 'content-type: application/json' \
-d '{"id":"vercel","name":"Vercel","adapter":"statuspage","baseUrl":"https://www.vercel-status.com"}'
curl -s -X POST localhost:3000/config/services/vercel/test
# {"ok":true,"overallStatus":"operational"}A connection test reaches the provider but records nothing: no sample, no incident, no notification. It is diagnostics, not history.
Light. Edit ./config.yml on the host — it is mounted read-only but re-read at
the start of every cycle. Add a provider and lower the interval:
docker logs -f isitdown-light
# ..."poll cycle finished","providers":3 ← before
# ..."poll cycle finished","providers":4 ← after, no restartMeasured: adding a fourth provider and changing pollIntervalMinutes from 3 to 1
took effect on the next cycle, and the following gap shrank to ~56s (one minute
minus jitter).
UI. Change the interval in Settings, or:
curl -s -X PATCH localhost:3000/config/settings \
-H 'content-type: application/json' -d '{"intervalMinutes":10}'
curl -s localhost:3000/status | jq .pollIntervalMinutes # 10Confirm invalid configuration is refused loudly rather than half-applied:
docker run --rm isitdown:light
# ..."isitdown light failed to start","error":"config file /app/config/config.yml
# was not found — mount it or set CONFIG_PATH" → exit 1
printf 'services: []\n' > /tmp/bad.yml
docker run --rm -v /tmp/bad.yml:/app/config/config.yml:ro isitdown:light
# ..."error":"config file ... is invalid: services: at least one service is required"Waiting for a real outage is not a test. This gives you a provider whose status you control, plus a sink that accepts the webhook — one throwaway container serves both.
mkdir -p /tmp/sw-test/html/api/v2
echo '{"status":{"indicator":"none"},"incidents":[]}' > /tmp/sw-test/html/api/v2/summary.json
cat > /tmp/sw-test/nginx.conf <<'CONF'
server {
listen 80;
location /api/v2/summary.json { root /usr/share/nginx/html; default_type application/json; }
location /hook { access_log /dev/stdout; return 200 '{"received":true}'; }
}
CONF
docker run -d --name fake-provider --network isitdown_default \
-v /tmp/sw-test/html:/usr/share/nginx/html:ro \
-v /tmp/sw-test/nginx.conf:/etc/nginx/conf.d/default.conf:ro \
nginx:alpinePoint the webhook channel at the sink. WEBHOOK_URL is read when the container
starts, so recreate it:
sed -i 's|^WEBHOOK_URL=.*|WEBHOOK_URL=http://fake-provider/hook|' .env
docker compose --profile ui up -d --force-recreateRegister the fake provider and enable the channel:
curl -s -X POST localhost:3000/config/services -H 'content-type: application/json' \
-d '{"id":"fake","name":"Fake Provider","adapter":"statuspage","baseUrl":"http://fake-provider"}'
curl -s -X PATCH localhost:3000/config/channels/webhook \
-H 'content-type: application/json' -d '{"enabled":true}'Now drive the transitions:
# 1. baseline — must send nothing
curl -s -X POST localhost:3000/poll | jq '{changes}' # {"changes": 0}
# 2. break it
cat > /tmp/sw-test/html/api/v2/summary.json <<'JSON'
{"status":{"indicator":"critical"},
"incidents":[{"id":"fake-1","name":"Everything is on fire","impact":"critical",
"status":"investigating","updated_at":"2026-08-19T18:00:00.000Z"}]}
JSON
curl -s -X POST localhost:3000/poll | jq '{changes}' # {"changes": 2}
# 3. poll again with nothing changed — must stay silent
curl -s -X POST localhost:3000/poll | jq '{changes}' # {"changes": 0}
curl -s localhost:3000/notifications | jq -r '.notifications[] | "\(.ok) \(.kind) \(.text | split("\n")[0])"'
# true incident_opened 🔴 Fake Provider — MAJOR OUTAGE
# true status_change 🔴 Fake Provider — MAJOR OUTAGEThe feed is newest first and persists in the data volume, so a database that has seen earlier runs will show their entries below these two.
Recovery, and restart safety:
echo '{"status":{"indicator":"none"},"incidents":[]}' > /tmp/sw-test/html/api/v2/summary.json
curl -s -X POST localhost:3000/poll | jq '{changes}' # {"changes": 2} → resolved + operational
docker compose --profile ui restart
curl -s -X POST localhost:3000/poll | jq '{changes}' # {"changes": 0} — nothing re-notified
curl -s 'localhost:3000/incidents?state=resolved' | jq '.page.items[] | {incidentId, startedAt, resolvedAt}'The same flow works on the Light edition: add the fake provider to config.yml,
set webhook.enabled: true, and the log shows the sends:
..."poll cycle finished","providers":4,"failed":0,"changes":2
..."notification sent","channel":"webhook","providerId":"fake","kind":"status_change"
..."notification sent","channel":"webhook","providerId":"fake","kind":"incident_opened"
Clean up:
docker rm -f fake-provider
curl -s -X DELETE localhost:3000/config/services/fake
curl -s -X PATCH localhost:3000/config/channels/webhook \
-H 'content-type: application/json' -d '{"enabled":false}'
sed -i 's|^WEBHOOK_URL=.*|WEBHOOK_URL=|' .env
rm -rf /tmp/sw-testTelegram is the channel most people actually want, and the one worth confirming for
real. Create a bot with @BotFather, send it a message,
then read your chat id from
https://api.telegram.org/bot<TOKEN>/getUpdates.
sed -i 's|^TELEGRAM_BOT_TOKEN=.*|TELEGRAM_BOT_TOKEN=123456:AA...|' .env
sed -i 's|^TELEGRAM_CHAT_ID=.*|TELEGRAM_CHAT_ID=-1001234567890|' .env
docker compose --profile ui up -d --force-recreate
curl -s -X PATCH localhost:3000/config/channels/telegram \
-H 'content-type: application/json' -d '{"enabled":true}'
curl -s -X POST localhost:3000/config/channels/telegram/test # {"ok":true}A failure comes back as {"ok":false,"error":"telegram notification failed: HTTP 400 (chat not found)"} —
the status code and Telegram's own description, never the token.
For the Light edition, set the same two variables, telegram.enabled: true in
config.yml, and restart the container so it picks the secrets up.
| Symptom | Likely cause |
|---|---|
| Light container exits immediately, exit 1 | Configuration. docker logs names the file, the path and the reason. |
the telegram channel is enabled but TELEGRAM_BOT_TOKEN is not set |
.env is not being passed. Check env_file and recreate the container — env is read at start. |
Container stuck starting forever |
The healthcheck never passed. Light: state.json is not being written, so no cycle completed. UI: /health is not answering. |
| Dashboard loads but the grid is empty | No cycle has run yet. POST /poll, or wait one interval. |
A provider shows unknown |
It has never been polled successfully. POST /config/services/<id>/test reports the actual error. |
Provider shows degraded but Incidents is empty |
Correct. Statuspage derives the indicator from component state too; there may be no incident record. |
Uptime reads 0% for a provider |
It has exactly one sample and it was not operational. It rises with the next cycles. |
A month column shows — |
No samples in that month. Deliberately not 0%, which would read as a month-long outage. |
| No notifications ever arrive | Is the channel enabled, and does its variable resolve? GET /config shows isSet per field. Then POST /config/channels/<id>/test. |
| Notifications arrive repeatedly for the same thing | Should be impossible — the diff engine is the only thing that decides. Capture docker logs and the /notifications feed. |
Dashboard shows raw keys like nav.overview |
A key with no catalog entry — should be impossible, the locale-guard tests catch every literal t("...") call before it ships. A dynamically built key (t(prefix + suffix)) is the one shape those tests cannot see; check the call site. |
Turn up the detail with LOG_LEVEL=debug, which logs every individual poll attempt
including retries.
UI edition only. Every response is JSON, including errors
({ "error": { "message": "..." } }) — a browser fetch that gets an HTML error page
back reports a parse failure instead of the real problem.
| Method | Path | Purpose |
|---|---|---|
GET |
/health |
Liveness. { status, providers, lastCycleAt }. |
GET |
/status |
Current status of every provider, plus last and next poll, plus maintenance: { active, upcoming } — windows running now and windows whose startsAt is still in the future; a window that has already ended but is still in the stored payload appears in neither list. A pure database read — safe to poll every 30s, which the dashboard does. Never reaches upstream. |
GET |
/history?provider=&days= |
Pre-aggregated daily buckets, 7/30/90-day uptime, month columns. days accepts 7, 30 or 90; anything else is a 400 naming them. Without provider, a summary across all of them. |
GET |
/incidents?provider=&state=&page=&pageSize= |
One page of the incident list: { active, page: { items, page, pageSize, total }, counts: { all, active, resolved } }. state is all (default), active or resolved; pageSize defaults to 20 and is capped at 100. A nonsense page, pageSize or state falls back to the first page of everything rather than a 400. counts carries all three states whatever the filter, and active is the open list the dashboard's hero card shows on every page. |
GET |
/incidents/:providerId/:incidentId |
Detail: the incident, the observed timeline, the action log of what was sent, the provider's other open incidents, and the last 24 polls. |
GET |
/maintenances?provider=&days= |
Declared maintenance windows — running, upcoming and past — as { maintenances }. days bounds how far back a closed window is still returned (default 90, max 365); provider narrows to one. Without provider, every enabled provider. |
GET |
/notifications?limit= |
What was actually sent, newest first. Capped at 200. |
GET |
/config |
Services, polling settings, channels. Channel credentials appear as variable names with an isSet flag — never values. |
POST |
/config/services |
Add a service. 201, or 409 on a duplicate id, or 400 naming the invalid field. |
PATCH DELETE |
/config/services/:id |
Edit or remove. Deleting cascades to that provider's samples, incidents and state, so nothing orphaned survives. |
PATCH |
/config/settings |
Polling settings. |
PATCH |
/config/channels/:id |
Enable/disable, and set variable names. Refuses a literal secret. |
POST |
/config/services/:id/test |
One live fetch against that provider. Records nothing. |
POST |
/config/channels/:id/test |
One test notification, through the dispatcher. |
GET PATCH |
/api/preferences |
{ theme, uiLocale, notificationLocale }. |
POST |
/poll |
Run a cycle now, through the scheduler. Returns the cycle summary. |
GET |
/metrics |
Prometheus exposition. The one non-JSON endpoint — see 6.2. |
GET |
/ |
The dashboard. |
At startup — and whenever a provider is added from the dashboard — the UI
edition reconstructs up to 90 days of history from the provider's public
incident feed (/api/v2/incidents.json for Statuspage-based providers), so
the uptime bars are not empty on a fresh container.
Reconstructed history is derived, not measured: a day is marked degraded or down only when a known incident overlapped it, and covered days without incidents are assumed operational. The public feed returns at most its 50 most recent incidents, so coverage varies per provider; days older than the feed's reach stay grey ("no data") and are excluded from the uptime percentages. Backfill never triggers notifications and never overwrites observed samples.
GET /metrics answers in the Prometheus text exposition format
(text/plain; version=0.0.4), so any Prometheus can scrape IsItDown with no
adapter in between:
scrape_configs:
- job_name: isitdown
static_configs:
- targets: ["isitdown-ui:3000"]Like /status, a scrape is a pure read of stored state and never reaches a
provider, so scraping every 15 seconds costs nothing upstream. Only enabled
providers are exported: a disabled one has rows in the database but no cycle
will ever refresh them, and an alert on a frozen gauge is worse than no series.
| Metric | Type | Labels | Meaning |
|---|---|---|---|
isitdown_providers_total |
gauge | — | Providers currently being polled. |
isitdown_provider_up |
gauge | provider, name |
1 when the provider reports itself fully operational, 0 otherwise — including before its first poll. |
isitdown_provider_status |
gauge | provider, status |
1 on the current normalised status, 0 on the other four. Tells degraded apart from major_outage, which _up cannot. |
isitdown_provider_active_incidents |
gauge | provider |
Open incidents on that provider's status page. |
isitdown_provider_failure_count |
gauge | provider |
Consecutive failed poll cycles. Non-zero means our own view is degraded, not that the provider is down. |
isitdown_provider_last_fetch_timestamp_seconds |
gauge | provider |
When its status was last read successfully. Absent until the first success. |
isitdown_poll_duration_seconds |
gauge | provider |
How long its last poll took, retries included. |
isitdown_polls_total |
counter | provider, outcome |
Polls attempted since start, outcome being success or failure. |
isitdown_notifications_total |
counter | channel, outcome |
Delivery attempts since start, outcome being sent or failed. |
isitdown_last_cycle_timestamp_seconds |
gauge | — | When the last cycle finished. Absent until this process has run one. |
The counters are per process, not per database: they start at zero on restart —
which is what Prometheus expects of a counter — rather than being derived from
the notifications table, which is pruned with the rest of the history and
would make a counter go backwards.
Two alerts worth having, and neither is "provider is down":
groups:
- name: isitdown
rules:
# The provider says it is down, and has for ten minutes.
- alert: ProviderDown
expr: isitdown_provider_up == 0
for: 10m
# Our own monitoring is blind — no successful cycle in fifteen minutes.
- alert: IsItDownStalled
expr: time() - isitdown_last_cycle_timestamp_seconds > 900There is no authentication: this is a local, single-operator dashboard. Do not publish port 3000 to a network you do not trust.
┌──────────────┐
│ Scheduler │ setTimeout re-armed after each cycle, ±10% jitter
└──────┬───────┘
│ every cycle, re-read from scratch
▼
┌──────────────┐ config.yml (Light)
│ ConfigSource │◀──────── or SQLite (UI)
└──────┬───────┘
▼
┌────────────────────────────────────┐ ┌──────────────┐
│ Poller │─────▶│ Adapters │──▶ provider status pages
│ stagger · retry · isolate failures│ └──────────────┘
└──────┬──────────────────────┬──────┘
│ previous state │ new state
▼ ▼
┌──────────────┐ ┌──────────────┐
│ StateStore │ │ Diff Engine │ the only thing that decides
│ JSON │ SQLite│ └──────┬───────┘ whether anything notifies
└──────────────┘ │ StatusChange[] (usually empty)
▼
┌────────────────┐
│ Dispatcher │ the only caller of Notifier.send
└───────┬────────┘
▼
Telegram · webhook
UI edition only: an Express server in the same process serves the dashboard and
the API from the same StateStore, and can ask the scheduler for a cycle now.
The core engine is edition-agnostic. src/core, src/adapters and src/notifiers
never import from src/light or src/ui — a test enforces it. Editions differ only
in the ConfigSource and StateStore they inject.
-
Scheduler — runs a cycle immediately, then re-arms a
setTimeoutat the interval ±10% jitter, so a slow cycle delays the next rather than overlapping it and a fleet of instances never hits a provider in lockstep. It re-reads the configuration every cycle, which is what makes UI changes take effect with no restart. A cycle that throws is logged and the loop keeps running. -
Poller — staggers providers 250ms apart, then runs them under
Promise.allSettledso one provider's failure cannot affect another's result. Up tomaxRetriesattempts each, exponential backoff plus jitter, every request under its own timeout. On exhausted retries it records the failure and leaves the stored status untouched. -
Adapters — turn a provider's raw response into the normalised shape:
interface NormalizedStatus { provider: string; // "github" overallStatus: "operational" | "degraded" | "partial_outage" | "major_outage" | "unknown"; activeIncidents: { id: string; name: string; impact: string; status: string; updatedAt: string }[]; fetchedAt: string; // ISO 8601, UTC }
statuspage.adapter.tsis generic and configured by base URL alone, which covers every Atlassian-hosted page. It throws on a network error, a non-2xx or an unparseable body so the poller's retry can act, but degrades quietly on a missing individual field — an incident with no title becomes an empty string, not a crash. The payload is validated withzod; a login page or an error blob is rejected. -
State Store — the last known
NormalizedStatusper provider, the consecutive failure count, and whether the "monitoring degraded" warning has already been sent. Light writes a JSON file through a temporary file and a rename, so a crash mid-write cannot truncate it; UI uses the built-innode:sqliteand also records the history the charts read. Both pass the same contract suite, so they are provably interchangeable.A failed fetch never overwrites the stored status. Keeping the last known state is what stops the next successful poll from being reported as a recovery that never happened.
-
Diff Engine — pure, synchronous, and the single authority on whether anything notifies. See 7.3.
-
Dispatcher — the only caller of
Notifier.sendin either edition. One payload per change per enabled channel, all underPromise.allSettled: a channel failure is recorded and logged but never blocks another channel or another change. Notifiers are rebuilt from the configuration every cycle, which is why enabling a channel needs no restart. -
Notifiers — Telegram and generic webhook. Message assembly is shared (
src/notifiers/formatting.ts), so channels cannot drift apart in what they report; only the transport differs. Emoji and layout live in the notifier, the words come from the shared catalogs.
This table is the behaviour — it is the diff engine's test suite, and new edge cases get added as rows rather than as one-off tests.
| Previous | New | Notifies |
|---|---|---|
| nothing yet (first poll) | anything | no — a baseline is not news, so a fresh container never bursts |
| operational | operational | no |
| operational | degraded | yes — status_change |
| degraded | major_outage | yes — escalation |
| major_outage | operational | yes — status_change plus incident_resolved |
| any | a new incident id appears | yes — incident_opened per incident |
| any | same incident, status or impact changed |
yes — incident_updated |
| any | same incident, only updatedAt or the title changed |
no — a provider bumping a timestamp is not an event |
| any | same incidents, different order | no — compared by id, so ordering cannot false-positive |
unknown |
anything | no status_change — there is no real baseline to compare |
| anything | unknown |
no status_change — a transition into "we don't know" is not news |
| N consecutive failed cycles | yes, once — monitoring_degraded, and not again until a success clears it |
|
| no window running | a declared window starts | yes — maintenance_started |
| a window running | the same window ends | yes — maintenance_ended, naming the status the provider came out in |
| a window running | anything else changes upstream (status, components, incidents) | no — suppressed until the window ends |
Suppression rule: while any maintenance window a provider declared is
running, nothing else about that provider is news — a status change, a new or
updated incident, a component flip, all of it stays quiet until the window
closes. The only exception is the window's own start and end. This means an
incident that opens inside a window and is still open when the window closes
never gets its own incident_opened message — the only announcement for it is
the open-incident count carried on maintenance_ended. If it is still open
once the window is long gone, nothing chases it further; check the dashboard.
Two things this does not change: maintenance rows are merged into the
dashboard's incident timeline, but only on the timeline's first page — older
maintenance history is available from /maintenances, not the timeline.
Uptime percentages are also unchanged — a sample taken while a window is
running counts exactly as it does today, maintenance or not.
A restart notifies nothing: state is reloaded from the store, and reloaded state compares equal to what produced it. Both store implementations are tested for this.
🔴 GitHub — MAJOR OUTAGE
Incident: API requests failing intermittently
Status: Investigating
Updated: 2026-08-19 14:32 UTC
https://www.githubstatus.com
🟢 GitHub — RESOLVED
Incident "API requests failing intermittently" has been resolved.
Updated: 2026-08-19 15:10 UTC
https://www.githubstatus.com
⚪ AWS — monitoring degraded
5 consecutive fetches failed. Last known status: Operational.
Updated: 2026-08-19 22:04 UTC
Emoji by severity: 🟢 operational · 🟡 degraded · 🟠 partial outage · 🔴 major outage · ⚪ unknown. A monitoring warning is always ⚪ — it is about IsItDown's own fetching, not the provider's state, so it never borrows the provider's colour. Timestamps stay UTC with an explicit suffix in every language.
- Provider unreachable — log it, keep the last known state, retry next cycle.
After
failureThresholdconsecutive failed cycles, one "monitoring degraded" warning; never a silent forever. - Malformed response — validated at the boundary. A missing optional field degrades; a fundamentally broken body throws so retry and failure accounting can act. One bad provider never crashes a cycle.
- Duplicate notifications — structurally prevented: the diff engine is the only thing that decides, and the dispatcher is the only thing that sends.
- Restart — state is reloaded from the store, so no false "everything changed" burst. Tested in both editions, including in the container.
- Rate limiting — providers are staggered within a cycle and the interval carries jitter, so neither one instance nor a fleet hammers a provider on the same second.
- Untrusted timestamps — a provider's
updatedAtahead of our clock cannot start an incident in the future; the start time is pinned to the poll that first saw it, while the provider's own claim is still recorded. - Concurrent writes — a cycle mutates state for every provider at once, so the file store serialises writes and gives each its own temporary file.
UI edition. Three states: light / dark / system, cycled from the header.
- Tokens, not per-component colours. Every colour is a CSS custom property.
css/tokens.cssis the only file in the dashboard allowed to contain a colour literal, and a test enforces that. The light palette sits on bare:root, the dark one overrides it in:root[data-theme="dark"], and the same overrides are mirrored underprefers-color-scheme: darkguarded by:root:not([data-theme="light"])so "system" works in both directions. All three blocks declare an identical token set, which a test also checks — a token defined in one theme only would render wrong. - Shadcn's components read the same tokens.
tokens.cssalso maps the semantic variables shadcn's primitives expect —--background,--foreground,--primary,--border,--ringand the rest — onto this palette, so a stock shadcn component needs no per-component override to fit the design system. Tailwind'sdark:variant is rebound, via@custom-variant, from shadcn's default.darkclass to this repo's own[data-theme="dark"]attribute, so the existing theme toggle — which sets an attribute, not a class — still drives it. None of this touches the pre-paint script below, which still only ever setsdata-theme. - Charts read the same tokens, so they never need a separate dark palette.
- System is the default. With no explicit choice, the theme follows the OS and reacts to it changing live, with no reload.
- Persisted twice: in
localStorage, so the inline<head>script can apply it before first paint and avoid a flash of the wrong theme; and in the settings table, so a fresh browser against the same instance starts where you left off. - Palette: Nocturne, from the Claude Design prototype. The light mode reads the same tonal ramps from the other end — no colour was invented, including the five severity colours, which have their own value per theme.
Two layers, en as the source and the fallback in both:
| Layer | Files | Used by |
|---|---|---|
| Notification text | src/core/i18n/<lang>.json |
both editions |
| Dashboard text | src/ui/web/locales/<lang>.json |
UI edition |
src/core/i18n/ is untouched by the dashboard's stack; the server still resolves
notification strings itself, in both editions, exactly as before. The dashboard
now resolves its own strings through react-i18next, configured with
keySeparator: false and single-brace interpolation ({name}, not the default
{{name}}) — the same flat area.subject.variant keys and placeholder syntax the
catalogs already used. Plurals use i18next's _one/_other key suffixes, not a
dotted pair. The catalogs are bundled, not fetched: src/ui/web/lib/i18n.ts
imports both JSON files directly, so they ship inside the JS bundle and
GET /locales/:lang.json no longer exists as a route.
Rules that are enforced by tests, not just documented:
- No user-facing literal in code. Every string is a flat dotted key; the value
lives in a catalog. Logger output, thrown
Errormessages, adapter ids and route paths are developer-facing and stay plain English. - Every catalog has exactly
en's key set, and every translated value carries the same named placeholders as its source. A string cannot ship half-translated. - Every key the dashboard asks for exists — a typo would otherwise render as the key itself in the browser.
- Never assemble a sentence from translated fragments. Word order differs per
language, so one key holds the whole sentence. Plurals are separate
_one/_otherkeys, not composed at the call site. - Dates, numbers, percentages and durations go through
Intl.*in the active locale. Notification timestamps are the deliberate exception: always UTC with an explicit suffix, so an operator reading alerts in two languages never has to guess. - The dashboard language and the notification language are separate settings — an English UI can send Italian alerts.
- Adding a notification language is one JSON file under
src/core/i18n/— no code change. Adding a dashboard language is one JSON file undersrc/ui/web/locales/plus one import line insrc/ui/web/lib/i18n.ts, since the catalogs are bundled rather than discovered from disk at runtime.
Shipping: en and it. Locale resolution is the stored preference, then en.
The Italian strings were written alongside the implementation and have not had a native review.
isitdown/
├── src/
│ ├── core/ (shared by both editions)
│ │ ├── types.ts NormalizedStatus, Incident, StatusChange, NotificationPayload
│ │ ├── adapter.interface.ts ServiceRef, FetchContext, Adapter
│ │ ├── notifier.interface.ts Notifier
│ │ ├── stateStore.interface.ts ProviderRuntimeState, StateStore
│ │ ├── configSource.interface.ts RuntimeConfig, ServiceDefinition, ChannelConfig, ConfigSource
│ │ ├── config.schema.ts zod schemas shared by the file loader and the UI's settings writes
│ │ ├── status.schema.ts validation for a persisted NormalizedStatus
│ │ ├── poller.ts one cycle: stagger, retry, isolation, failure accounting
│ │ ├── diffEngine.ts the sole authority on whether a notification fires
│ │ ├── notificationDispatcher.ts the only caller of Notifier.send
│ │ ├── scheduler.ts the loop; re-reads config every cycle
│ │ ├── logger.ts
│ │ └── i18n/ notification strings, edition-agnostic
│ │ ├── index.ts lookup + en fallback + UTC formatting
│ │ ├── en.json source locale
│ │ └── it.json
│ ├── adapters/ (shared)
│ │ ├── statuspage.adapter.ts generic Atlassian Statuspage adapter
│ │ ├── rss.adapter.ts generic RSS / Atom incident-feed adapter
│ │ └── index.ts registry keyed by adapter id
│ ├── notifiers/ (shared)
│ │ ├── formatting.ts emoji, severity labels, message assembly
│ │ ├── telegram.notifier.ts
│ │ ├── webhook.notifier.ts
│ │ └── index.ts registry keyed by channel id
│ ├── light/ (Light edition only)
│ │ ├── index.ts entrypoint
│ │ ├── runtime.ts wiring, shared with the end-to-end test
│ │ ├── healthcheck.ts state-file freshness
│ │ ├── fileStateStore.ts JSON file, atomic writes
│ │ └── config/
│ │ ├── schema.ts config.yml shape
│ │ └── loadConfig.ts YAML + ${ENV} substitution + validation
│ └── ui/ (UI edition only)
│ ├── server.ts entrypoint
│ ├── runtime.ts wiring, shared with the API tests
│ ├── app.ts Express app: routes, static dashboard, JSON errors
│ ├── routePaths.ts the dashboard's route table, shared with the client router
│ ├── healthcheck.ts probes /health
│ ├── sqliteStateStore.ts StateStore + history, one transaction per save
│ ├── historyStore.interface.ts the history contract (UI is its only consumer)
│ ├── history.ts uptime and incident aggregation
│ ├── backfill.ts reconstructs 90 days of history from a provider's incidents on first boot
│ ├── dbConfigSource.ts config from SQLite; resolves secrets by variable name
│ ├── metrics.ts the Prometheus scrape surface: gauges from the store, counters in memory
│ ├── mapLane.ts the map's own 15-minute poll cycle: component lists → located points, no notifications
│ ├── mapStore.ts map_points + map_geo_state persistence
│ ├── geo/ resolveLocation.ts + the IATA/cloud-region lookup tables it resolves against
│ ├── db/ open.ts, migrate.ts, seed.ts
│ ├── routes/ status, history, incidents, notifications, config, preferences, map, metrics
│ └── web/ the dashboard: react, vite, shadcn/ui
│ ├── index.html pre-paint theme script, fonts, #root
│ ├── main.tsx provider tree: i18n, query, theme, router
│ ├── App.tsx console shell: rail, header, view container
│ ├── routes.tsx hash routes
│ ├── components/ui/ shadcn primitives
│ ├── components/ rail, header, poll indicator, charts/
│ ├── views/ overview, providers, incidents, incident,
│ │ history, settings
│ ├── hooks/ queries, theme, rail, busy
│ ├── lib/ api, types, chartConfig, format, i18n
│ ├── css/base.css Tailwind entry point: imports tailwindcss, tokens, motion
│ ├── css/tokens.css the only file with a colour literal
│ ├── css/motion.css keyframes, entry animations, transitions
│ └── locales/ en.json (source) + it.json
├── tools/
│ └── copy-assets.mjs copies i18n and dashboard-locale catalogs into dist (the
│ dashboard bundle itself is Vite's own output, not this script's)
├── test/
│ ├── core/ diff engine, poller, scheduler, dispatcher, i18n, schemas
│ │ └── stateStore.contract.ts one suite every StateStore implementation must pass
│ ├── adapters/
│ ├── notifiers/
│ ├── light/
│ ├── ui/ store contract, aggregation, every API route, theme and locale guards
│ ├── fixtures/statuspage/ payloads recorded from the live pages, never fetched in a test
│ ├── helpers/
│ └── integration/ *.itest.ts — fake provider and webhook receiver end to end
├── design/ Claude Design prototypes (git-ignored: on disk, not in a clone)
├── Dockerfile builder → light → dev → ui (dev is FROM builder; ui is FROM light)
├── docker-compose.yml both editions as profiles
├── docker-compose.dev.yml dev override: UI edition live from src/, Vite watch-builds the bundle
├── config.example.yml tracked template; config.yml is git-ignored
├── .env.example secret variable names, never values
├── .nvmrc .npmrc pins Node 24 and makes an older one fail loudly
├── tsconfig.json server TypeScript
├── tsconfig.light.json the Light build: excludes src/ui
├── tsconfig.web.json the dashboard: DOM lib + react-jsx
├── vite.config.ts bundle, dev proxy, vitest config
└── components.json shadcn CLI config
Dashboard component and hook tests are colocated under web/ as *.test.tsx,
next to what they cover, and picked up from there by Vitest — everywhere else in
the tree keeps to the test/ convention above.
Golden rule: src/core, src/adapters and src/notifiers never import from
src/light or src/ui. Edition-specific behaviour is injected through the shared
interfaces instead. A test enforces this, including the edition-only dependencies.
| Layer | Choice | Notes |
|---|---|---|
| Runtime | Node.js 24 | Required: the built-in SQLite driver and native TypeScript type stripping both need it. |
| Language | TypeScript, strict | Plus erasableSyntaxOnly and rewriteRelativeImportExtensions, so tsc emits real .js while node --test runs the .ts sources directly. |
| HTTP client | global fetch |
Already in the runtime. |
| Scheduling | setTimeout, re-armed with jitter |
A slow cycle delays the next instead of overlapping. |
| Storage | JSON file (Light) · built-in node:sqlite (UI) |
No native module, so no compiler in any build stage. |
| Validation | zod |
Every external input: config files, provider payloads, database rows, catalogs. |
| Config parsing | yaml |
|
| Test runner | built-in node:test, plus Vitest |
node:test runs the server, core, adapters, notifiers and the fs-based guards straight from .ts; Vitest and React Testing Library cover src/ui/web/, because Node's type stripping does not transform JSX. |
| Dashboard | React 19 + Vite + Tailwind v4 + shadcn/ui | Bundled into dist/ui/public; Radix primitives themed entirely from tokens.css. |
| Charts | Recharts, via shadcn's chart wrapper |
Data is still aggregated server-side; the client never re-derives a percentage. |
| Client routing | react-router 8, hash-based |
Path-based routing was unavailable: /incidents/:providerId/:incidentId is already an API endpoint. |
| Client i18n | react-i18next |
Flat catalogs, single-brace interpolation, bundled rather than fetched. |
| Server state | TanStack Query | 30-second refetchInterval, refetch on focus, held while a dialog or a field is in use. |
| Container | one multi-stage Dockerfile |
--target light / --target ui, node:24-alpine. |
Runtime dependencies, exhaustively: zod, yaml (both editions) and express
(UI). Everything the dashboard uses — React, Vite, Tailwind, shadcn/ui's Radix
primitives, TanStack Query, react-i18next, Recharts and the rest — is a
devDependency compiled into static assets at build time, so the ui image gains
a bundle, not a dependency tree. Dev dependencies otherwise: typescript,
@types/node, @types/express, @types/react, @types/react-dom, Vite's own
plugins, Vitest and React Testing Library.
Two modes, for two different loops:
npm run dev:ui # local: Express on :3000, Vite's dev server on :5173 with HMR
npm run dev:docker # container: Vite watch-builds into dist/, Express serves :3000dev:ui runs the server and Vite as two local processes together (concurrently).
The browser talks to Vite on 5173 — that is where HMR lives — and Vite's own
dev-server proxy forwards every API path (/status, /config, /history,
/incidents, /notifications, /poll, /api, /health) to the real Express
server on 3000. Visiting :3000 directly instead serves whatever is already sitting
in dist/ui/public, which is not live.
dev:docker is the faithful mode: one port, the same URL and port an operator
would use, nothing in between — which is what makes the smoke checks in
5.1 meaningful against it too. docker-compose.dev.yml
overrides the isitdown-ui service to build the dev target (tagged
isitdown:dev, never isitdown:ui), mounts ./src, vite.config.ts and
tsconfig.web.json read-only, and runs npx vite build --watch & exec node --watch src/ui/server.ts — Vite rewrites the bundle in dist/ui/public on every
source change, in the background, and the one Express process on :3000 always
serves whatever Vite last wrote there.
WEB_DIR has to be set explicitly in every dev mode, local or containerised: the
server's own default (in app.ts, ./public/ relative to itself) only resolves
correctly when the module runs from dist/ui/, where Vite's build actually lands.
Running the source module directly — which is exactly what dev mode does — makes
that same default resolve beside src/ui/, where nothing named public/ exists
any more: the dashboard's source now lives under src/ui/web/ instead. Both
dev:ui's npm script and
docker-compose.dev.yml set WEB_DIR to the built dist/ui/public path
explicitly for this reason.
| Edit | dev:ui |
dev:docker |
|---|---|---|
.tsx, .ts or CSS under web/ |
HMR, no reload | rebuilds in about a second, then hard refresh |
a locale JSON under web/locales/ |
HMR | rebuild, hard refresh |
any server .ts |
node --watch restarts |
node --watch restarts |
a dependency in package.json |
npm install (then restart) |
npm run dev:docker -- --build |
the Dockerfile |
no effect — dev:ui never touches Docker |
npm run dev:docker -- --build |
A rebuild changes the bundle's filename, not just its content — asset names are
content-hashed — so "hard refresh" is enough in every case: the freshly written
index.html always points at the new hash, and there is no stale-cache case to
special-case around.
Two things dev mode does not do. It does not type-check — stripping types is not
compiling them, so npm run typecheck stays mandatory. And neither mode's dist/
is the one that ships: shipping still goes out the normal way,
docker compose --profile ui up -d --build # back to the built imageTo tell a running container's mode apart: docker compose ps shows
ghcr.io/devmanfre/isitdown:ui-latest
for the built image and isitdown:dev for dev mode; docker inspect -f '{{.Config.Cmd}}' isitdown-ui shows node dist/ui/server.js for the built image,
and sh -c "npx vite build --watch & exec node --watch src/ui/server.ts" for dev
mode.
npm test # node:test suites + vitest run
npm run test:integration # end-to-end suite: test/**/*.itest.ts
npm run typecheck # server tsconfig + dashboard tsconfig (tsconfig.web.json)
npm run build:light # tsc + copy assets, excluding src/ui
npm run build:ui # tsc + vite build + copy assetsNo test ever touches a live provider. Adapters are tested against payloads
recorded from the real status pages and kept under test/fixtures/; HTTP behaviour
runs against a local server.
Notable suites:
- Diff engine — the whole table in 7.3, including every case that must not notify.
- State store contract — one suite, run unchanged against both implementations, so they are provably interchangeable. It includes the restart case.
- Poller — retry count and growing backoff, per-provider isolation, a hanging provider not blocking a healthy one, and the monitoring warning firing once.
- Scheduler — mocked timers and injected jitter: cadence, config re-read per cycle, a manual poll joining an in-flight cycle, a failed cycle not killing the loop.
- Notifiers — outbound request shape per transition, and the assertion that a failed Telegram send never puts the token in its error.
- API — every route against a real server on a temp database, including the assertion that no response body contains a value from the environment.
- Theme and locale guards — no hex outside the token file (the scan also
catches a hex smuggled into a Tailwind arbitrary value, e.g.
bg-[#1a1a2e]), a semantic-token parity assertion that every shadcn variable resolves to a palettevar()and is declared identically in all three theme blocks, catalog parity, everyt()key resolving, and a scan for an English sentence typed straight into JSX — a heuristic, knowingly weaker than the exact text-node scan it replaced, since JSX gives no parse-free way to tell a translated expression from a literal. - End to end — a fake provider and a webhook receiver: a transition delivers
exactly one notification, an unchanged cycle none, a restart none, an unreachable
provider keeps its last known state, and the entrypoint stays alive between cycles
and exits 0 on
SIGTERM.
The dashboard is real TypeScript now, checked by its own tsconfig.web.json
rather than a JSDoc-driven pass over plain JavaScript; its components and hooks
are tested with Vitest and React Testing Library, colocated as *.test.tsx next
to what they cover.
- Validate every external input with
zodat the boundary; trust internals. - One adapter per file under
src/adapters/, one notifier per file undersrc/notifiers/, each implementing the shared interface. - Notification-sending logic lives only on the diff engine → dispatcher path.
- Any string a human reads is a catalog key, written in English first.
- Secrets from environment variables only — never a config file, never a database, never a log line.
- New dashboard surfaces get prototyped in
design/before implementation. - A shadcn component exists for most surfaces — use it, and
cn()for conditional classes, rather than writing a new component class. - Colours reach a chart only through
chartConfig, never as a literal and never as a runtime-built token name.
Two workflows, and the version lives in exactly one place.
.github/workflows/ci.yml runs on every pull request and on every push to dev
or main: Node from .nvmrc, npm ci, then the same four commands a
contributor runs locally — typecheck, test, test:integration, build.
A release is a tag. package.json stays private (nothing is published to
npm) but its version is the single source of truth:
npm version minor # preversion runs typecheck + both test suites first,
# then the commit and the vX.Y.Z tag are created
git push --follow-tagsPushing the tag runs .github/workflows/release.yml, which re-runs the checks
(a tag push does not trigger CI), refuses to continue if the tag and
package.json disagree, builds both targets for linux/amd64 and
linux/arm64, pushes the four GHCR tags with an SBOM and provenance, signs both
digests with keyless cosign, and creates the GitHub release.
The release notes are generated from the log by tools/release-notes.mjs, which
leans on the commit convention: <emoji> <TITLE> - <description> parses, so the
changelog is grouped by surface (POLLER, UI, DOCKER, …) rather than being a
commit dump. Preview it for any range before tagging:
npm run release-notes -- v0.1.0 HEAD- Commits:
<emoji> <TITLE> - <description>, English, gitmoji.
Delivered:
- v1 — Light edition: polling, the diff engine, Telegram and generic-webhook
notifications,
config.ymlwith environment-referenced secrets, a JSON state store with atomic writes,ghcr.io/devmanfre/isitdown:light-latest. - v1.1 — UI prototyping: the dashboard explored in Claude Design and kept in
design/claude-design-prototypes/. Option3a, the navigable console, is the implementation reference; the dark palette and the longer Italian label lengths were validated there rather than discovered later. - v1.2 — UI edition: that design as an Express + vanilla-ES-module dashboard over
SQLite, with configuration managed at runtime and applied on the next cycle without
a restart.
ghcr.io/devmanfre/isitdown:ui-latest, builtFROMthe Light image. - v1.3 — history: per-provider uptime and incident history with status-page daily
bars and 7/30/90-day views, aggregated server-side and served from
/history. - v1.4 — dark mode and i18n: token-based light/dark/system theming with a
persisted preference, and a localised dashboard (
en,it) on top of the localised notification messages both editions already share.
Still open:
- Adapters for providers not on Atlassian Statuspage.
- Discord and Slack channels with rich embeds — both webhook-shaped, so they slot in
behind the existing
Notifierinterface. - Multi-recipient routing: different channels per provider or per severity.
- Scheduled-maintenance awareness. The adapter ignores Statuspage's
scheduled_maintenances, and the severity model has no maintenance state. - A native review of the Italian strings.
Explicit non-goals: multi-user auth (this is a local, single-operator dashboard), status pages behind a login, and a packaged mobile app.
The Claude Code tooling (.claude/, CLAUDE.md) and the merge filter itself
(.mergeexclude, .githooks/, scripts/) are tracked on dev only. On
main none of those paths exist — neither in the commit nor in the working tree.
Everything else (source, docs, config) flows normally from dev to main.
Because the filter is not readable from main, it is installed into this clone's
.git directory, which every branch shares.
git switch dev
scripts/setup-hooks.shIt copies:
From (dev) |
To (shared by every branch) |
|---|---|
scripts/git-merge-clean |
$GIT_DIR/merge-clean |
.githooks/* |
$GIT_DIR/hooks/* |
.mergeexclude |
$GIT_DIR/merge-exclude |
and installs the git mergeclean alias. Re-run it after changing
.mergeexclude or scripts/git-merge-clean.
git switch main
git mergeclean dev # not `git merge dev`git mergeclean merges the branch, drops the paths listed in the exclude list,
commits with the repo's 🔀 subject format, and removes those paths from the
working tree. It refuses to run on a dirty tree. Genuine conflicts outside the
excluded paths stop the run so you can resolve them and git commit as usual.
| Piece | Role |
|---|---|
$GIT_DIR/merge-exclude |
the path list |
$GIT_DIR/merge-clean |
the merge wrapper (--sync purges, --guard checks) |
$GIT_DIR/hooks/post-checkout |
purges the excluded paths after a branch switch |
$GIT_DIR/hooks/pre-merge-commit, pre-commit |
abort any commit that would add an excluded path to a branch that does not track it |
A plain git merge dev on main is refused by the guard hooks — run
git merge --abort and use git mergeclean instead. git commit --no-verify
bypasses the guard if you ever genuinely need to.
- The setup is per clone and cannot be automatic. Git never runs hooks taken
from a clone, and a clone that only ever checks out
mainhas nothing to install from. On a new machine, check outdevand run the setup before merging anything intomain. - Until the setup is run, nothing is enforced. A plain
git merge devon a fresh clone will pull.claude/andCLAUDE.mdintomainas soon as you resolve the conflicts it raises.
- Edit
.claude/,CLAUDE.md,.mergeexclude,.githooks/andscripts/only while ondev— onmainthey do not exist. - The purge only deletes files that
devalso has and that are byte-identical to it, so machine-local files (.claude/settings.local.json) and local edits are never touched.
