Initial commit — status checker service
All checks were successful
Docker / build-and-push (push) Successful in 35s
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:
76
src/checker.ts
Normal file
76
src/checker.ts
Normal 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
24
src/index.ts
Normal 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
42
src/npm.ts
Normal 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
98
src/page.ts
Normal 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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user