Add admin UI: per-host visibility overrides and manual targets
NPM stays the source of truth for which domains exist; this adds an optional enrichment layer on top of the existing NPM_INCLUDE_PATTERNS filtering, gated behind ADMIN_PASSWORD (disabled entirely if unset). - Force-show/force-hide any NPM-discovered host regardless of what the include pattern would otherwise decide - Per-host display name and check path overrides - Manually add targets that aren't proxied through NPM at all - Every override falls back to the NPM-derived default when unset — a host with no override behaves exactly as before this existed Host overrides and manual targets live in SQLite (better-sqlite3, WAL mode) and are re-read on every 60s check tick, so admin changes take effect within one cycle with no restart. NPM host discovery keeps its original 10-minute cadence — only the local override reads happen every tick. The core merge (NPM hosts + overrides + manual targets -> final check list) is a pure function in targets.ts, kept separate from the checker's I/O for testability. Verified end-to-end against a local mock NPM server (auth, force-show/hide, rename, custom check path, manual targets, reset-to-default all propagate correctly) and against the actual Docker build/runtime (better-sqlite3's native addon builds and loads correctly in the Alpine image). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
106
src/admin.ts
Normal file
106
src/admin.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Router } from 'express';
|
||||
import { config } from './config.js';
|
||||
import { fetchAllProxyHosts, matchesIncludePatterns } from './npm.js';
|
||||
import {
|
||||
getAllOverrides,
|
||||
upsertOverride,
|
||||
deleteOverride,
|
||||
listManualTargets,
|
||||
createManualTarget,
|
||||
updateManualTarget,
|
||||
deleteManualTarget,
|
||||
type Visibility,
|
||||
} from './db.js';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
function checkAdminPassword(req: import('express').Request): boolean {
|
||||
return !!config.adminPassword && req.get('X-Admin-Password') === config.adminPassword;
|
||||
}
|
||||
|
||||
// Every route below requires the header — stateless, same shape as wisp's
|
||||
// upload gate, no session/cookie to manage.
|
||||
router.use((req, res, next) => {
|
||||
if (!config.adminPassword) {
|
||||
res.status(503).json({ error: 'admin disabled — ADMIN_PASSWORD not set' });
|
||||
return;
|
||||
}
|
||||
if (req.path === '/auth/check') { next(); return; }
|
||||
if (!checkAdminPassword(req)) {
|
||||
res.status(401).json({ error: 'bad password' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
router.post('/auth/check', (req, res) => {
|
||||
res.json({ ok: config.adminPassword != null && checkAdminPassword(req) });
|
||||
});
|
||||
|
||||
// Full NPM-discovered host list, each annotated with whether the current
|
||||
// include-pattern would show it and any existing override — the admin UI
|
||||
// needs the *unfiltered* view to let you force-show a pattern-excluded host.
|
||||
router.get('/hosts', async (_req, res) => {
|
||||
try {
|
||||
const hosts = await fetchAllProxyHosts();
|
||||
const overrides = getAllOverrides();
|
||||
const data = hosts.map(h => {
|
||||
const domain = h.domain_names[h.domain_names.length - 1];
|
||||
const override = overrides.get(domain);
|
||||
return {
|
||||
domain,
|
||||
matchesPattern: matchesIncludePatterns(domain),
|
||||
visibility: override?.visibility ?? 'default',
|
||||
displayName: override?.displayName ?? null,
|
||||
checkPath: override?.checkPath ?? null,
|
||||
sortOrder: override?.sortOrder ?? null,
|
||||
};
|
||||
});
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e instanceof Error ? e.message : 'upstream error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/hosts/:domain', (req, res) => {
|
||||
const { domain } = req.params;
|
||||
const body = req.body as { visibility?: Visibility; displayName?: string | null; checkPath?: string | null; sortOrder?: number | null };
|
||||
|
||||
if (body.visibility && !['default', 'hidden', 'shown'].includes(body.visibility)) {
|
||||
res.status(400).json({ error: 'invalid visibility' });
|
||||
return;
|
||||
}
|
||||
|
||||
upsertOverride(domain, body);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/hosts/:domain', (req, res) => {
|
||||
deleteOverride(req.params.domain);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/manual-targets', (_req, res) => {
|
||||
res.json(listManualTargets());
|
||||
});
|
||||
|
||||
router.post('/manual-targets', (req, res) => {
|
||||
const body = req.body as { name?: string; url?: string; checkPath?: string | null; sortOrder?: number | null };
|
||||
if (!body.name || !body.url) {
|
||||
res.status(400).json({ error: 'name and url are required' });
|
||||
return;
|
||||
}
|
||||
res.json(createManualTarget({ name: body.name, url: body.url, checkPath: body.checkPath, sortOrder: body.sortOrder }));
|
||||
});
|
||||
|
||||
router.put('/manual-targets/:id', (req, res) => {
|
||||
const body = req.body as { name?: string; url?: string; checkPath?: string | null; enabled?: boolean; sortOrder?: number | null };
|
||||
const ok = updateManualTarget(req.params.id, body);
|
||||
if (!ok) { res.status(404).json({ error: 'not found' }); return; }
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/manual-targets/:id', (req, res) => {
|
||||
deleteManualTarget(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
274
src/adminPage.ts
Normal file
274
src/adminPage.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
export function renderAdminPage(): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>admin / status</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; --accent: #00e87a; --danger: #ff3300;
|
||||
}
|
||||
*, *::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: 13px; min-height: 100vh;
|
||||
padding: 32px 16px 64px;
|
||||
}
|
||||
#app { width: 100%; max-width: 860px; margin: 0 auto; }
|
||||
h1 { font-size: 18px; letter-spacing: 2px; color: #e8c400; margin-bottom: 24px; }
|
||||
h1 span { color: var(--dim); font-weight: 400; }
|
||||
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--dim); margin: 28px 0 12px; }
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 20px; }
|
||||
input[type=text], input[type=password], input[type=number] {
|
||||
background: #050505; border: 1px solid var(--border); color: var(--text);
|
||||
font-family: inherit; font-size: 13px; padding: 6px 8px; border-radius: 4px;
|
||||
}
|
||||
select {
|
||||
background: #050505; border: 1px solid var(--border); color: var(--text);
|
||||
font-family: inherit; font-size: 13px; padding: 5px 6px; border-radius: 4px;
|
||||
}
|
||||
button {
|
||||
background: #111; border: 1px solid var(--border); color: var(--text);
|
||||
font-family: inherit; font-size: 12px; padding: 6px 12px; border-radius: 4px; cursor: pointer;
|
||||
}
|
||||
button:hover { border-color: var(--accent); color: var(--accent); }
|
||||
button.primary { border-color: var(--accent); color: var(--accent); }
|
||||
button.danger:hover { border-color: var(--danger); color: var(--danger); }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
th { text-align: left; color: var(--dim); text-transform: uppercase; font-size: 10px; letter-spacing: 0.06em;
|
||||
padding: 0 8px 8px 0; border-bottom: 1px solid var(--border); }
|
||||
td { padding: 8px 8px 8px 0; border-bottom: 1px solid #111; vertical-align: middle; }
|
||||
.dim { color: var(--dim); }
|
||||
.badge { font-size: 10px; padding: 2px 6px; border-radius: 3px; border: 1px solid var(--border); }
|
||||
.badge.yes { color: var(--accent); border-color: var(--accent); }
|
||||
.badge.no { color: var(--dim); }
|
||||
.row-actions { display: flex; gap: 6px; }
|
||||
.error-banner { color: var(--danger); font-size: 12px; margin-top: 10px; }
|
||||
.hint { color: var(--dim); font-size: 12px; }
|
||||
.add-form { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
|
||||
.add-form input { flex: 1; min-width: 120px; }
|
||||
#lock-screen { max-width: 320px; margin: 80px auto 0; text-align: center; }
|
||||
#lock-screen input { width: 100%; margin-bottom: 10px; }
|
||||
#lock-screen button { width: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<h1>admin<span> / status</span></h1>
|
||||
|
||||
<div id="lock-screen" class="card">
|
||||
<p class="hint" style="margin-bottom: 14px;">admin password</p>
|
||||
<input id="password" type="password" placeholder="password" autocomplete="off" />
|
||||
<button id="unlock-btn" class="primary">unlock</button>
|
||||
<div id="lock-error" class="error-banner"></div>
|
||||
</div>
|
||||
|
||||
<div id="panel" style="display:none;">
|
||||
<div class="card">
|
||||
<p class="hint">NPM stays the source of truth for which domains exist. Overrides here are enrichment only — leave a field blank and it falls back to the NPM name / pattern-match result.</p>
|
||||
</div>
|
||||
|
||||
<h2>npm hosts</h2>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>domain</th><th>pattern</th><th>visibility</th><th>display name</th><th>check path</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody id="hosts-body"><tr><td colspan="6" class="dim">loading…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>manual targets</h2>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>name</th><th>url</th><th>check path</th><th>enabled</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody id="manual-body"><tr><td colspan="5" class="dim">loading…</td></tr></tbody>
|
||||
</table>
|
||||
<div class="add-form">
|
||||
<input id="new-name" type="text" placeholder="name" />
|
||||
<input id="new-url" type="text" placeholder="https://example.com" />
|
||||
<input id="new-path" type="text" placeholder="check path (optional)" />
|
||||
<button id="add-btn" class="primary">add</button>
|
||||
</div>
|
||||
<div id="manual-error" class="error-banner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
let password = sessionStorage.getItem('admin_password') || '';
|
||||
|
||||
function headers(extra) {
|
||||
return Object.assign({ 'X-Admin-Password': password }, extra || {});
|
||||
}
|
||||
|
||||
async function api(path, opts) {
|
||||
const res = await fetch('/api/admin' + path, Object.assign({}, opts, { headers: headers((opts && opts.headers) || {}) }));
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(function () { return {}; });
|
||||
throw new Error(body.error || ('request failed: ' + res.status));
|
||||
}
|
||||
return res.status === 204 ? null : res.json();
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
async function tryUnlock() {
|
||||
const input = document.getElementById('password');
|
||||
const err = document.getElementById('lock-error');
|
||||
password = input.value;
|
||||
err.textContent = '';
|
||||
try {
|
||||
const body = await api('/auth/check', { method: 'POST' });
|
||||
if (!body.ok) { err.textContent = 'wrong password'; return; }
|
||||
sessionStorage.setItem('admin_password', password);
|
||||
document.getElementById('lock-screen').style.display = 'none';
|
||||
document.getElementById('panel').style.display = 'block';
|
||||
loadAll();
|
||||
} catch (e) {
|
||||
err.textContent = e.message || 'unlock failed';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('unlock-btn').addEventListener('click', tryUnlock);
|
||||
document.getElementById('password').addEventListener('keydown', function (e) { if (e.key === 'Enter') tryUnlock(); });
|
||||
|
||||
function visibilitySelect(current) {
|
||||
const opts = ['default', 'hidden', 'shown'];
|
||||
return '<select class="vis-select">' + opts.map(function (o) {
|
||||
return '<option value="' + o + '"' + (o === current ? ' selected' : '') + '>' + o + '</option>';
|
||||
}).join('') + '</select>';
|
||||
}
|
||||
|
||||
async function loadHosts() {
|
||||
const body = document.getElementById('hosts-body');
|
||||
try {
|
||||
const hosts = await api('/hosts');
|
||||
if (!hosts.length) { body.innerHTML = '<tr><td colspan="6" class="dim">no NPM hosts found</td></tr>'; return; }
|
||||
body.innerHTML = hosts.map(function (h) {
|
||||
const willShow = h.visibility === 'hidden' ? false : h.visibility === 'shown' ? true : h.matchesPattern;
|
||||
return '<tr data-domain="' + esc(h.domain) + '">' +
|
||||
'<td>' + esc(h.domain) + (willShow ? ' <span class="badge yes">shown</span>' : ' <span class="badge no">hidden</span>') + '</td>' +
|
||||
'<td><span class="badge ' + (h.matchesPattern ? 'yes' : 'no') + '">' + (h.matchesPattern ? 'match' : 'no match') + '</span></td>' +
|
||||
'<td>' + visibilitySelect(h.visibility) + '</td>' +
|
||||
'<td><input type="text" class="name-input" placeholder="' + esc(h.domain) + '" value="' + esc(h.displayName || '') + '" /></td>' +
|
||||
'<td><input type="text" class="path-input" placeholder="/" value="' + esc(h.checkPath || '') + '" /></td>' +
|
||||
'<td class="row-actions"><button class="save-btn primary">save</button></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
|
||||
body.querySelectorAll('.save-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', async function () {
|
||||
const tr = btn.closest('tr');
|
||||
const domain = tr.getAttribute('data-domain');
|
||||
const visibility = tr.querySelector('.vis-select').value;
|
||||
const displayName = tr.querySelector('.name-input').value.trim() || null;
|
||||
const checkPath = tr.querySelector('.path-input').value.trim() || null;
|
||||
btn.textContent = 'saving…';
|
||||
try {
|
||||
await api('/hosts/' + encodeURIComponent(domain), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ visibility: visibility, displayName: displayName, checkPath: checkPath }),
|
||||
});
|
||||
await loadHosts();
|
||||
} catch (e) {
|
||||
btn.textContent = 'failed';
|
||||
setTimeout(function () { btn.textContent = 'save'; }, 1500);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
body.innerHTML = '<tr><td colspan="6" class="error-banner">' + esc(e.message) + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadManual() {
|
||||
const body = document.getElementById('manual-body');
|
||||
try {
|
||||
const targets = await api('/manual-targets');
|
||||
if (!targets.length) { body.innerHTML = '<tr><td colspan="5" class="dim">no manual targets yet</td></tr>'; return; }
|
||||
body.innerHTML = targets.map(function (t) {
|
||||
return '<tr data-id="' + esc(t.id) + '">' +
|
||||
'<td>' + esc(t.name) + '</td>' +
|
||||
'<td class="dim">' + esc(t.url) + '</td>' +
|
||||
'<td class="dim">' + esc(t.checkPath || '—') + '</td>' +
|
||||
'<td><input type="checkbox" class="enabled-check" ' + (t.enabled ? 'checked' : '') + ' /></td>' +
|
||||
'<td class="row-actions"><button class="delete-btn danger">delete</button></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
|
||||
body.querySelectorAll('.enabled-check').forEach(function (cb) {
|
||||
cb.addEventListener('change', async function () {
|
||||
const id = cb.closest('tr').getAttribute('data-id');
|
||||
await api('/manual-targets/' + encodeURIComponent(id), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: cb.checked }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
body.querySelectorAll('.delete-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', async function () {
|
||||
const id = btn.closest('tr').getAttribute('data-id');
|
||||
await api('/manual-targets/' + encodeURIComponent(id), { method: 'DELETE' });
|
||||
await loadManual();
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
body.innerHTML = '<tr><td colspan="5" class="error-banner">' + esc(e.message) + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('add-btn').addEventListener('click', async function () {
|
||||
const name = document.getElementById('new-name').value.trim();
|
||||
const url = document.getElementById('new-url').value.trim();
|
||||
const checkPath = document.getElementById('new-path').value.trim() || null;
|
||||
const err = document.getElementById('manual-error');
|
||||
err.textContent = '';
|
||||
if (!name || !url) { err.textContent = 'name and url are required'; return; }
|
||||
try {
|
||||
await api('/manual-targets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name, url: url, checkPath: checkPath }),
|
||||
});
|
||||
document.getElementById('new-name').value = '';
|
||||
document.getElementById('new-url').value = '';
|
||||
document.getElementById('new-path').value = '';
|
||||
await loadManual();
|
||||
} catch (e) {
|
||||
err.textContent = e.message;
|
||||
}
|
||||
});
|
||||
|
||||
function loadAll() {
|
||||
loadHosts();
|
||||
loadManual();
|
||||
}
|
||||
|
||||
// If a password is already remembered for this tab session, skip the lock screen.
|
||||
if (password) {
|
||||
api('/auth/check', { method: 'POST' }).then(function (body) {
|
||||
if (body.ok) {
|
||||
document.getElementById('lock-screen').style.display = 'none';
|
||||
document.getElementById('panel').style.display = 'block';
|
||||
loadAll();
|
||||
}
|
||||
}).catch(function () {});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { fetchProxyHosts } from './npm.js';
|
||||
import { fetchAllProxyHosts, matchesIncludePatterns } from './npm.js';
|
||||
import { getAllOverrides, listManualTargets } from './db.js';
|
||||
import { computeTargets, type CheckTarget, type RawNpmHost } from './targets.js';
|
||||
|
||||
export type CheckStatus = 'up' | 'down' | 'unknown';
|
||||
|
||||
@@ -22,7 +24,7 @@ const HOST_REFRESH_MS = 10 * 60_000;
|
||||
|
||||
let store: StatusStore = { hosts: [], lastRefreshed: new Date().toISOString() };
|
||||
let lastHostRefresh = 0;
|
||||
let targets: { name: string; url: string }[] = [];
|
||||
let rawHosts: RawNpmHost[] = [];
|
||||
|
||||
async function checkUrl(url: string): Promise<{ status: CheckStatus; code: number | null; latencyMs: number | null }> {
|
||||
const controller = new AbortController();
|
||||
@@ -39,22 +41,33 @@ async function checkUrl(url: string): Promise<{ status: CheckStatus; code: numbe
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshHosts(): Promise<void> {
|
||||
// Refreshing the NPM host list hits their API and re-authenticates if
|
||||
// needed, so it stays on the original 10-minute cadence. Overrides and
|
||||
// manual targets are local SQLite reads — cheap enough to re-read on every
|
||||
// 60s check tick, so admin changes take effect within one cycle instead of
|
||||
// waiting on the NPM refresh.
|
||||
async function refreshNpmHosts(): 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]}`,
|
||||
}));
|
||||
const hosts = await fetchAllProxyHosts();
|
||||
rawHosts = hosts.map(h => {
|
||||
const domain = h.domain_names[h.domain_names.length - 1];
|
||||
return { domain, matchesPattern: matchesIncludePatterns(domain) };
|
||||
});
|
||||
lastHostRefresh = Date.now();
|
||||
console.log(`[checker] loaded ${targets.length} targets from NPM`);
|
||||
console.log(`[checker] loaded ${rawHosts.length} candidate hosts from NPM`);
|
||||
} catch (e) {
|
||||
console.error('[checker] failed to fetch hosts from NPM:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function currentTargets(): CheckTarget[] {
|
||||
return computeTargets(rawHosts, getAllOverrides(), listManualTargets());
|
||||
}
|
||||
|
||||
async function runChecks(): Promise<void> {
|
||||
if (Date.now() - lastHostRefresh > HOST_REFRESH_MS) await refreshHosts();
|
||||
if (Date.now() - lastHostRefresh > HOST_REFRESH_MS) await refreshNpmHosts();
|
||||
|
||||
const targets = currentTargets();
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const results = await Promise.all(
|
||||
@@ -70,7 +83,7 @@ async function runChecks(): Promise<void> {
|
||||
export function getStore(): StatusStore { return store; }
|
||||
|
||||
export async function startChecker(): Promise<void> {
|
||||
await refreshHosts();
|
||||
await refreshNpmHosts();
|
||||
await runChecks();
|
||||
setInterval(runChecks, CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
8
src/config.ts
Normal file
8
src/config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import path from 'node:path';
|
||||
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT ?? '3035', 10),
|
||||
dataDir: process.env.DATA_DIR ?? path.resolve('data'),
|
||||
// Admin UI is disabled entirely if unset — no open-by-default gate.
|
||||
adminPassword: process.env.ADMIN_PASSWORD || null,
|
||||
};
|
||||
169
src/db.ts
Normal file
169
src/db.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { config } from './config.js';
|
||||
|
||||
// 'default' = follow NPM_INCLUDE_PATTERNS as before; 'hidden' / 'shown'
|
||||
// force the opposite of whatever the pattern would have decided.
|
||||
export type Visibility = 'default' | 'hidden' | 'shown';
|
||||
|
||||
export interface HostOverride {
|
||||
domain: string;
|
||||
visibility: Visibility;
|
||||
displayName: string | null;
|
||||
checkPath: string | null;
|
||||
sortOrder: number | null;
|
||||
}
|
||||
|
||||
export interface ManualTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
checkPath: string | null;
|
||||
enabled: boolean;
|
||||
sortOrder: number | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
fs.mkdirSync(config.dataDir, { recursive: true });
|
||||
|
||||
export const db = new Database(path.join(config.dataDir, 'statuspage.db'));
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
db.exec(`
|
||||
-- NPM stays the source of truth for which domains exist. This table is
|
||||
-- pure enrichment: a row only ever overrides one field at a time in
|
||||
-- practice, and every field falls back to the NPM-derived default when
|
||||
-- null / 'default'.
|
||||
CREATE TABLE IF NOT EXISTS host_overrides (
|
||||
domain TEXT PRIMARY KEY,
|
||||
visibility TEXT NOT NULL DEFAULT 'default', -- 'default' | 'hidden' | 'shown'
|
||||
display_name TEXT,
|
||||
check_path TEXT,
|
||||
sort_order INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manual_targets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
check_path TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
function newId(): string {
|
||||
return randomBytes(9).toString('base64url');
|
||||
}
|
||||
|
||||
export function getAllOverrides(): Map<string, HostOverride> {
|
||||
const rows = db.prepare(`SELECT * FROM host_overrides`).all() as {
|
||||
domain: string;
|
||||
visibility: Visibility;
|
||||
display_name: string | null;
|
||||
check_path: string | null;
|
||||
sort_order: number | null;
|
||||
}[];
|
||||
const map = new Map<string, HostOverride>();
|
||||
for (const r of rows) {
|
||||
map.set(r.domain, {
|
||||
domain: r.domain,
|
||||
visibility: r.visibility,
|
||||
displayName: r.display_name,
|
||||
checkPath: r.check_path,
|
||||
sortOrder: r.sort_order,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function upsertOverride(
|
||||
domain: string,
|
||||
fields: { visibility?: Visibility; displayName?: string | null; checkPath?: string | null; sortOrder?: number | null },
|
||||
): void {
|
||||
const existing = db.prepare(`SELECT * FROM host_overrides WHERE domain = ?`).get(domain) as
|
||||
| { domain: string; visibility: Visibility; display_name: string | null; check_path: string | null; sort_order: number | null }
|
||||
| undefined;
|
||||
|
||||
const next = {
|
||||
visibility: fields.visibility ?? existing?.visibility ?? 'default',
|
||||
display_name: fields.displayName !== undefined ? fields.displayName : (existing?.display_name ?? null),
|
||||
check_path: fields.checkPath !== undefined ? fields.checkPath : (existing?.check_path ?? null),
|
||||
sort_order: fields.sortOrder !== undefined ? fields.sortOrder : (existing?.sort_order ?? null),
|
||||
};
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO host_overrides (domain, visibility, display_name, check_path, sort_order)
|
||||
VALUES (@domain, @visibility, @display_name, @check_path, @sort_order)
|
||||
ON CONFLICT(domain) DO UPDATE SET
|
||||
visibility = excluded.visibility,
|
||||
display_name = excluded.display_name,
|
||||
check_path = excluded.check_path,
|
||||
sort_order = excluded.sort_order`,
|
||||
).run({ domain, ...next });
|
||||
}
|
||||
|
||||
export function deleteOverride(domain: string): void {
|
||||
db.prepare(`DELETE FROM host_overrides WHERE domain = ?`).run(domain);
|
||||
}
|
||||
|
||||
export function listManualTargets(): ManualTarget[] {
|
||||
const rows = db.prepare(`SELECT * FROM manual_targets ORDER BY created_at ASC`).all() as {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
check_path: string | null;
|
||||
enabled: number;
|
||||
sort_order: number | null;
|
||||
created_at: number;
|
||||
}[];
|
||||
return rows.map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
checkPath: r.check_path,
|
||||
enabled: r.enabled === 1,
|
||||
sortOrder: r.sort_order,
|
||||
createdAt: r.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export function createManualTarget(fields: { name: string; url: string; checkPath?: string | null; sortOrder?: number | null }): ManualTarget {
|
||||
const id = newId();
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
db.prepare(
|
||||
`INSERT INTO manual_targets (id, name, url, check_path, enabled, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?)`,
|
||||
).run(id, fields.name, fields.url, fields.checkPath ?? null, fields.sortOrder ?? null, createdAt);
|
||||
return { id, name: fields.name, url: fields.url, checkPath: fields.checkPath ?? null, enabled: true, sortOrder: fields.sortOrder ?? null, createdAt };
|
||||
}
|
||||
|
||||
export function updateManualTarget(
|
||||
id: string,
|
||||
fields: { name?: string; url?: string; checkPath?: string | null; enabled?: boolean; sortOrder?: number | null },
|
||||
): boolean {
|
||||
const existing = db.prepare(`SELECT * FROM manual_targets WHERE id = ?`).get(id) as
|
||||
| { id: string; name: string; url: string; check_path: string | null; enabled: number; sort_order: number | null }
|
||||
| undefined;
|
||||
if (!existing) return false;
|
||||
|
||||
const next = {
|
||||
name: fields.name ?? existing.name,
|
||||
url: fields.url ?? existing.url,
|
||||
check_path: fields.checkPath !== undefined ? fields.checkPath : existing.check_path,
|
||||
enabled: fields.enabled !== undefined ? (fields.enabled ? 1 : 0) : existing.enabled,
|
||||
sort_order: fields.sortOrder !== undefined ? fields.sortOrder : existing.sort_order,
|
||||
};
|
||||
|
||||
db.prepare(
|
||||
`UPDATE manual_targets SET name = @name, url = @url, check_path = @check_path, enabled = @enabled, sort_order = @sort_order WHERE id = @id`,
|
||||
).run({ id, ...next });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function deleteManualTarget(id: string): void {
|
||||
db.prepare(`DELETE FROM manual_targets WHERE id = ?`).run(id);
|
||||
}
|
||||
19
src/index.ts
19
src/index.ts
@@ -1,21 +1,34 @@
|
||||
import express from 'express';
|
||||
import { config } from './config.js';
|
||||
import { startChecker, getStore } from './checker.js';
|
||||
import { renderPage } from './page.js';
|
||||
import { renderAdminPage } from './adminPage.js';
|
||||
import { router as adminRouter } from './admin.js';
|
||||
|
||||
const PORT = parseInt(process.env.PORT ?? '3035', 10);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/api/status', (_req, res) => {
|
||||
res.json(getStore());
|
||||
});
|
||||
|
||||
app.use('/api/admin', adminRouter);
|
||||
|
||||
app.get('/admin', (_req, res) => {
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(renderAdminPage());
|
||||
});
|
||||
|
||||
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}`);
|
||||
app.listen(config.port, () => {
|
||||
console.log(`[status] listening on :${config.port}`);
|
||||
if (!config.adminPassword) {
|
||||
console.log('[status] ADMIN_PASSWORD not set — /admin is disabled');
|
||||
}
|
||||
});
|
||||
|
||||
startChecker().catch(e => {
|
||||
|
||||
16
src/npm.ts
16
src/npm.ts
@@ -11,7 +11,11 @@ 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;
|
||||
export const INCLUDE_PATTERNS = rawPatterns;
|
||||
|
||||
export function matchesIncludePatterns(domain: string): boolean {
|
||||
return INCLUDE_PATTERNS.some(p => p.test(domain));
|
||||
}
|
||||
|
||||
let token: string | null = null;
|
||||
let tokenExpiry = 0;
|
||||
@@ -29,14 +33,14 @@ async function getToken(): Promise<string> {
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function fetchProxyHosts(): Promise<ProxyHost[]> {
|
||||
// Every enabled proxy host, regardless of NPM_INCLUDE_PATTERNS — the admin
|
||||
// UI needs the full set to let you force-show something the pattern would
|
||||
// otherwise exclude. checker.ts applies the pattern (or an override) itself.
|
||||
export async function fetchAllProxyHosts(): 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)))
|
||||
);
|
||||
return all.filter(h => h.enabled);
|
||||
}
|
||||
|
||||
79
src/targets.ts
Normal file
79
src/targets.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { HostOverride, ManualTarget } from './db.js';
|
||||
|
||||
export interface CheckTarget {
|
||||
key: string; // domain for NPM-derived, "manual:<id>" for manual — used for override lookups and stable identity
|
||||
name: string;
|
||||
url: string;
|
||||
source: 'npm' | 'manual';
|
||||
}
|
||||
|
||||
export interface RawNpmHost {
|
||||
domain: string;
|
||||
matchesPattern: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges NPM-discovered hosts with local overrides and manual targets into
|
||||
* the final check list. Pure function — no I/O — so it's cheap to unit test
|
||||
* and cheap to re-run every check tick (every 60s) even though the NPM host
|
||||
* list itself is only refreshed every 10 minutes.
|
||||
*
|
||||
* NPM is always the source of truth for *which domains exist*; overrides
|
||||
* are strictly additive enrichment. A host with no override row behaves
|
||||
* exactly as it did before this feature existed: included iff it matches
|
||||
* NPM_INCLUDE_PATTERNS, named after its own domain.
|
||||
*/
|
||||
export function computeTargets(
|
||||
rawHosts: RawNpmHost[],
|
||||
overrides: Map<string, HostOverride>,
|
||||
manualTargets: ManualTarget[],
|
||||
): CheckTarget[] {
|
||||
const npmTargets: (CheckTarget & { sortOrder: number | null })[] = [];
|
||||
|
||||
for (const host of rawHosts) {
|
||||
const override = overrides.get(host.domain);
|
||||
const visibility = override?.visibility ?? 'default';
|
||||
|
||||
const visible =
|
||||
visibility === 'hidden' ? false :
|
||||
visibility === 'shown' ? true :
|
||||
host.matchesPattern;
|
||||
|
||||
if (!visible) continue;
|
||||
|
||||
const name = override?.displayName || host.domain;
|
||||
const checkPath = override?.checkPath || '/';
|
||||
npmTargets.push({
|
||||
key: host.domain,
|
||||
name,
|
||||
url: `https://${host.domain}${checkPath}`,
|
||||
source: 'npm',
|
||||
sortOrder: override?.sortOrder ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const manualTargetsMapped: (CheckTarget & { sortOrder: number | null })[] = manualTargets
|
||||
.filter(t => t.enabled)
|
||||
.map(t => ({
|
||||
key: `manual:${t.id}`,
|
||||
name: t.name,
|
||||
url: t.checkPath ? `${t.url.replace(/\/$/, '')}${t.checkPath}` : t.url,
|
||||
source: 'manual',
|
||||
sortOrder: t.sortOrder,
|
||||
}));
|
||||
|
||||
// Stable sort: explicit sortOrder wins (ascending), unordered entries
|
||||
// keep their relative discovery order and sink after any ordered ones.
|
||||
const combined = [...npmTargets, ...manualTargetsMapped];
|
||||
const withIndex = combined.map((t, i) => ({ t, i }));
|
||||
withIndex.sort((a, b) => {
|
||||
const aHas = a.t.sortOrder != null;
|
||||
const bHas = b.t.sortOrder != null;
|
||||
if (aHas && bHas) return (a.t.sortOrder as number) - (b.t.sortOrder as number);
|
||||
if (aHas) return -1;
|
||||
if (bHas) return 1;
|
||||
return a.i - b.i;
|
||||
});
|
||||
|
||||
return withIndex.map(({ t }) => ({ key: t.key, name: t.name, url: t.url, source: t.source }));
|
||||
}
|
||||
Reference in New Issue
Block a user