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:
Fredrik Johansson
2026-07-10 15:40:57 +02:00
parent e9d132cd02
commit b83d7aec01
15 changed files with 1187 additions and 24 deletions

106
src/admin.ts Normal file
View 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
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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>`;
}

View File

@@ -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
View 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
View 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);
}

View File

@@ -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 => {

View File

@@ -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
View 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 }));
}