Initial commit — status checker service
All checks were successful
Docker / build-and-push (push) Successful in 35s

Polls Nginx Proxy Manager API for proxy hosts matching configurable
domain patterns (NPM_INCLUDE_PATTERNS), HTTP-checks each every 60s,
serves results as JSON (/api/status) and a terminal-aesthetic HTML
page (/). Port 3035.

NPM connection and domain filter patterns are fully configurable via
environment variables — see README and .env.example.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-07 21:36:43 +02:00
commit a300ea1b43
13 changed files with 1922 additions and 0 deletions

76
src/checker.ts Normal file
View File

@@ -0,0 +1,76 @@
import { fetchProxyHosts } from './npm.js';
export type CheckStatus = 'up' | 'down' | 'unknown';
export interface HostResult {
name: string;
url: string;
status: CheckStatus;
statusCode: number | null;
latencyMs: number | null;
checkedAt: string;
}
export interface StatusStore {
hosts: HostResult[];
lastRefreshed: string;
}
const TIMEOUT_MS = 8_000;
const CHECK_INTERVAL_MS = 60_000;
const HOST_REFRESH_MS = 10 * 60_000;
let store: StatusStore = { hosts: [], lastRefreshed: new Date().toISOString() };
let lastHostRefresh = 0;
let targets: { name: string; url: string }[] = [];
async function checkUrl(url: string): Promise<{ status: CheckStatus; code: number | null; latencyMs: number | null }> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
const start = Date.now();
try {
const res = await fetch(url, { signal: controller.signal, redirect: 'follow' });
const latencyMs = Date.now() - start;
clearTimeout(timer);
return { status: res.ok || res.status < 500 ? 'up' : 'down', code: res.status, latencyMs };
} catch {
clearTimeout(timer);
return { status: 'down', code: null, latencyMs: null };
}
}
async function refreshHosts(): Promise<void> {
try {
const hosts = await fetchProxyHosts();
targets = hosts.map(h => ({
name: h.domain_names[h.domain_names.length - 1],
url: `https://${h.domain_names[h.domain_names.length - 1]}`,
}));
lastHostRefresh = Date.now();
console.log(`[checker] loaded ${targets.length} targets from NPM`);
} catch (e) {
console.error('[checker] failed to fetch hosts from NPM:', e);
}
}
async function runChecks(): Promise<void> {
if (Date.now() - lastHostRefresh > HOST_REFRESH_MS) await refreshHosts();
if (targets.length === 0) return;
const results = await Promise.all(
targets.map(async t => {
const r = await checkUrl(t.url);
return { name: t.name, url: t.url, status: r.status, statusCode: r.code, latencyMs: r.latencyMs, checkedAt: new Date().toISOString() } satisfies HostResult;
})
);
store = { hosts: results, lastRefreshed: new Date().toISOString() };
}
export function getStore(): StatusStore { return store; }
export async function startChecker(): Promise<void> {
await refreshHosts();
await runChecks();
setInterval(runChecks, CHECK_INTERVAL_MS);
}

24
src/index.ts Normal file
View File

@@ -0,0 +1,24 @@
import express from 'express';
import { startChecker, getStore } from './checker.js';
import { renderPage } from './page.js';
const PORT = parseInt(process.env.PORT ?? '3035', 10);
const app = express();
app.get('/api/status', (_req, res) => {
res.json(getStore());
});
app.get('/', (_req, res) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(renderPage(getStore()));
});
app.listen(PORT, () => {
console.log(`[status] listening on :${PORT}`);
});
startChecker().catch(e => {
console.error('[status] checker failed to start:', e);
process.exit(1);
});

42
src/npm.ts Normal file
View File

@@ -0,0 +1,42 @@
export interface ProxyHost {
domain_names: string[];
forward_host: string;
forward_port: number;
enabled: boolean;
}
const NPM_BASE = process.env.NPM_BASE_URL ?? 'http://localhost:81';
const NPM_EMAIL = process.env.NPM_EMAIL ?? '';
const NPM_SECRET = process.env.NPM_SECRET ?? '';
const rawPatterns = (process.env.NPM_INCLUDE_PATTERNS ?? '\\.goonk\\.se$,\\.dev\\.xplwd\\.com$')
.split(',').map(p => new RegExp(p.trim()));
const INCLUDE_PATTERNS = rawPatterns;
let token: string | null = null;
let tokenExpiry = 0;
async function getToken(): Promise<string> {
if (token && Date.now() < tokenExpiry) return token;
const res = await fetch(`${NPM_BASE}/api/tokens`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: NPM_EMAIL, secret: NPM_SECRET }),
});
const data = await res.json() as { token: string; expires: string };
token = data.token;
tokenExpiry = new Date(data.expires).getTime() - 60_000;
return token;
}
export async function fetchProxyHosts(): Promise<ProxyHost[]> {
const tok = await getToken();
const res = await fetch(`${NPM_BASE}/api/nginx/proxy-hosts`, {
headers: { Authorization: `Bearer ${tok}` },
});
const all = await res.json() as ProxyHost[];
return all.filter(h =>
h.enabled &&
h.domain_names.some(d => INCLUDE_PATTERNS.some(p => p.test(d)))
);
}

98
src/page.ts Normal file
View File

@@ -0,0 +1,98 @@
import type { StatusStore } from './checker.js';
export function renderPage(store: StatusStore): string {
const upCount = store.hosts.filter(h => h.status === 'up').length;
const downCount = store.hosts.filter(h => h.status === 'down').length;
const allUp = downCount === 0 && store.hosts.length > 0;
const rows = store.hosts.map(h => {
const cls = h.status === 'up' ? 'up' : h.status === 'down' ? 'down' : 'unknown';
const indicator = h.status === 'up' ? '●' : h.status === 'down' ? '●' : '○';
const latency = h.latencyMs != null ? `${h.latencyMs}ms` : '—';
const code = h.statusCode != null ? String(h.statusCode) : '—';
return `<tr>
<td><span class="dot ${cls}">${indicator}</span></td>
<td><a href="${h.url}" target="_blank" rel="noopener">${h.name}</a></td>
<td class="dim">${code}</td>
<td class="dim">${latency}</td>
</tr>`;
}).join('\n');
const summaryClass = allUp ? 'up' : downCount > 0 ? 'down' : 'unknown';
const summaryText = store.hosts.length === 0
? 'checking…'
: allUp
? 'all systems operational'
: `${downCount} host${downCount !== 1 ? 's' : ''} down`;
const checked = new Date(store.lastRefreshed).toUTCString();
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="60">
<title>status / goonk</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap');
:root {
--bg: #080808; --panel: #0f0f0f; --border: #1e1e1e;
--text: #d6d6d6; --dim: #555;
--up: #00e87a; --down: #ff3300; --unknown: #888;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg); color: var(--text);
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 14px; min-height: 100vh;
display: flex; justify-content: center; align-items: flex-start;
padding: 32px 16px 64px;
}
#app { width: 100%; max-width: 640px; }
header { border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 20px;
display: flex; justify-content: space-between; align-items: baseline; }
.brand { font-size: 18px; font-weight: 700; letter-spacing: 2px; color: #e8c400; }
.brand span { color: var(--dim); font-weight: 400; }
.summary { font-size: 13px; }
.summary.up { color: var(--up); }
.summary.down { color: var(--down); }
.summary.unknown { color: var(--unknown); }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; font-size: 11px; color: var(--dim); text-transform: uppercase;
letter-spacing: 0.08em; padding: 0 8px 8px 0; border-bottom: 1px solid var(--border); }
td { padding: 9px 8px 9px 0; border-bottom: 1px solid #111; vertical-align: middle; }
td a { color: var(--text); text-decoration: none; }
td a:hover { color: var(--up); }
.dim { color: var(--dim); font-size: 12px; }
.dot { font-size: 10px; }
.dot.up { color: var(--up); }
.dot.down { color: var(--down); }
.dot.unknown { color: var(--unknown); }
footer { margin-top: 20px; font-size: 11px; color: var(--dim);
display: flex; justify-content: space-between; border-top: 1px solid var(--border); padding-top: 10px; }
</style>
</head>
<body>
<div id="app">
<header>
<div class="brand">status<span> / goonk</span></div>
<div class="summary ${summaryClass}">${summaryText}</div>
</header>
<table>
<thead><tr>
<th style="width:24px"></th>
<th>host</th>
<th style="width:60px">code</th>
<th style="width:80px">latency</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
<footer>
<span>refreshes every 60s</span>
<span>checked ${checked}</span>
</footer>
</div>
</body>
</html>`;
}