feat(admin): add read-only health page #24

Merged
eleith merged 1 commits from feat/admin-health into main 2026-11-12 20:32:09 +00:00
10 changed files with 540 additions and 4 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
'@goto/web': minor
'@goto/worker': minor
'@goto/cli': minor
---
Add a read-only Health page with safe active settings, report data cutoff and a manual bounded check of the existing worker health endpoint. Use the configured worker base URL without adding a startup dependency. Make the worker check and its result prominent with a bordered action button, checking indicator and status banner. Warn when enabled analytics reports are unavailable or more than 75 minutes old.
+34 -3
View File
@@ -3,7 +3,7 @@ import { expect, test } from './support/test.ts';
test.use({ withWorker: true });
test('shared credentials protect overview, domain and link pages and sign-out removes access', async ({
test('shared credentials protect admin pages and sign-out removes access', async ({
page,
appServer
}) => {
@@ -140,7 +140,7 @@ test('shared credentials protect overview, domain and link pages and sign-out re
await expect(browsers.getByRole('rowheader', { name: 'Other', exact: true })).toBeVisible();
const overviewLink = page
.getByRole('navigation', { name: 'Administration', exact: true })
.getByRole('link');
.getByRole('link', { name: 'Goto', exact: true });
await expect(overviewLink).toHaveAttribute('href', '/admin?period=all');
await expect(page.getByRole('link', { name: 'Overview', exact: true })).toHaveCount(0);
await expect(graph).toHaveAttribute('aria-busy', 'false');
@@ -293,6 +293,24 @@ test('shared credentials protect overview, domain and link pages and sign-out re
);
await expect(visitsValue).toHaveText('2');
await page
.getByRole('navigation', { name: 'Administration', exact: true })
.getByRole('link', { name: 'Health', exact: true })
.click();
await expect(page).toHaveURL(`${appServer.origin}/admin/health`);
await expect(page.getByRole('heading', { name: 'Health', exact: true })).toBeVisible();
await expect(page.getByRole('region', { name: 'Application', exact: true })).toContainText(
'Shared credentials'
);
await expect(
page.getByRole('region', { name: 'Analytics', exact: true }).getByRole('alert')
).toHaveCount(0);
const workerHealth = page.getByRole('region', { name: 'Worker', exact: true });
await expect(workerHealth.getByRole('status')).toHaveText('Not checked');
await workerHealth.getByRole('button', { name: 'Test worker', exact: true }).click();
await expect(workerHealth.getByRole('alert')).toContainText('Worker is reachable.');
await expect(workerHealth.locator('time')).toBeVisible();
const html = await page.content();
await expect(html).not.toContain('e2e-only-password');
await page.getByRole('button', { name: 'Sign out', exact: true }).click();
@@ -305,7 +323,7 @@ test('shared credentials protect overview, domain and link pages and sign-out re
test.describe('analytics disabled', () => {
test.use({ withWorker: false, retentionMonths: 0 });
test('overview remains inventory-only without analytics controls or columns', async ({
test('disabled analytics keeps inventory and settings usable without traffic controls', async ({
page,
appServer
}) => {
@@ -351,5 +369,18 @@ test.describe('analytics disabled', () => {
await expect(page.getByRole('region', { name: 'Visits over time', exact: true })).toHaveCount(
0
);
await page
.getByRole('navigation', { name: 'Administration', exact: true })
.getByRole('link', { name: 'Health', exact: true })
.click();
await expect(
page.getByRole('region', { name: 'Analytics', exact: true }).locator('dd')
).toHaveText(['Disabled', 'Disabled', 'Disabled']);
await expect(
page.getByRole('region', { name: 'Analytics', exact: true }).getByRole('alert')
).toHaveCount(0);
await expect(
page.getByRole('region', { name: 'Worker', exact: true }).getByRole('status')
).toHaveText('Not checked');
});
});
+3 -1
View File
@@ -24,7 +24,7 @@ await new Promise((resolve, reject) =>
);
process.env.PORT = String(address.port);
const configuration = {
url: { app: `http://localhost:${address.port}` },
url: { app: `http://localhost:${address.port}`, worker: 'http://goto-worker:9000' },
auth: { credentials: { username: 'example', password: 'e2e-only-password' } },
analytics: { retention_months: Number(process.env.GOTO_E2E_RETENTION_MONTHS ?? '3') },
domains: {
@@ -118,4 +118,6 @@ async function startWorker() {
});
});
if (ready.type !== 'ready') throw new Error('Invalid worker readiness');
configuration.url.worker = `http://127.0.0.1:${ready.port}`;
await writeFile(join(runtimeDirectory, 'config/goto.yaml'), JSON.stringify(configuration));
}
@@ -52,6 +52,10 @@
<a href={overviewHref} aria-current={page.url.pathname === '/admin' ? 'page' : undefined}
>{title}</a
>
<a
href="/admin/health"
aria-current={page.url.pathname === '/admin/health' ? 'page' : undefined}>Health</a
>
</nav>
<div class="nav-actions">
<ThemeToggle />
@@ -96,6 +100,13 @@
gap: var(--space-5);
}
nav {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-5);
}
a {
color: var(--color-text);
font-weight: 600;
@@ -0,0 +1,29 @@
import { afterEach, expect, test, vi } from 'vitest';
import { isWorkerReachable } from './worker-health.js';
afterEach(() => vi.restoreAllMocks());
test('checks the configured worker base with a bounded health request', async () => {
const signal = new AbortController().signal;
const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(signal);
const request = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('ok'));
expect(await isWorkerReachable('http://goto-worker:9000/private/')).toBe(true);
expect(request).toHaveBeenCalledExactlyOnceWith('http://goto-worker:9000/private/healthz', {
signal
});
expect(timeout).toHaveBeenCalledExactlyOnceWith(3_000);
});
test('an unsuccessful health response is unreachable', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('unavailable', { status: 503 }));
expect(await isWorkerReachable('http://goto-worker:9000')).toBe(false);
});
test('transport and timeout failures are unreachable without exposing errors', async () => {
const request = vi.spyOn(globalThis, 'fetch');
request.mockRejectedValueOnce(new Error('private connection detail'));
expect(await isWorkerReachable('http://goto-worker:9000')).toBe(false);
request.mockRejectedValueOnce(new DOMException('Timed out', 'TimeoutError'));
expect(await isWorkerReachable('http://goto-worker:9000')).toBe(false);
});
+13
View File
@@ -0,0 +1,13 @@
const HEALTH_TIMEOUT_MS = 3_000;
export async function isWorkerReachable(workerBaseUrl: string): Promise<boolean> {
try {
const response = await fetch(`${workerBaseUrl.replace(/\/+$/, '')}/healthz`, {
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS)
});
await response.body?.cancel();
return response.ok;
} catch {
return false;
}
}
+3
View File
@@ -31,6 +31,9 @@
--radius-control: 0.5rem;
--radius-card: 0.75rem;
--shadow-card: 0 1px 6px light-dark(rgb(0 0 0 / 6%), rgb(0 0 0 / 40%));
--color-success: light-dark(#059669, #34d399);
--color-success-bg: light-dark(#ecfdf5, #022c22);
--color-success-border: light-dark(#a7f3d0, #064e3b);
--color-danger: light-dark(#991b1b, #ffb4b4);
--color-danger-bg: light-dark(#fef2f2, #450a0a);
--color-danger-border: light-dark(#fca5a5, #a34242);
@@ -0,0 +1,36 @@
import packageJson from '../../../../../../package.json' with { type: 'json' };
import { readOverviewDetails } from '#lib/server/visit-reports.js';
import { isWorkerReachable } from '#lib/server/worker-health.js';
import type { Actions, PageServerLoad } from './$types';
const MAX_REPORT_AGE_MS = 75 * 60 * 1000;
export const load = (async ({ locals }) => {
const configuration = locals.snapshot.configuration;
const retentionMonths = configuration.analytics.retention_months;
const report = retentionMonths === 0 ? null : await readOverviewDetails(configuration, []);
const reportDataAsOf = report?.overview.period.asOf ?? null;
const reportDelayed =
reportDataAsOf !== null && Date.now() - Date.parse(reportDataAsOf) > MAX_REPORT_AGE_MS;
const authMode = configuration.auth.oidc ? ('oidc' as const) : ('credentials' as const);
return {
appVersion: packageJson.version,
appUrl: configuration.url.app,
authMode,
retentionMonths,
reportDataAsOf,
reportDelayed,
redirectStatusCode: configuration.defaults.status_code,
forwardQuery: configuration.defaults.forward_query,
fallbackDestination: configuration.defaults.fallback,
workerUrl: configuration.url.worker
};
}) satisfies PageServerLoad;
export const actions = {
checkWorker: async ({ locals }) => {
const reachable = await isWorkerReachable(locals.snapshot.configuration.url.worker);
return { workerCheck: { reachable, checkedAt: new Date().toISOString() } };
}
} satisfies Actions;
@@ -0,0 +1,240 @@
<script lang="ts">
import { enhance } from '$app/forms';
import IconCheckCircle from 'virtual:icons/ph/check-circle';
import IconWarningCircle from 'virtual:icons/ph/warning-circle';
import IconSpinner from 'virtual:icons/ph/spinner';
import AdminPage from '#lib/components/admin/AdminPage.svelte';
import type { ActionData, PageData, SubmitFunction } from './$types';
let { data, form }: { data: PageData; form: ActionData | null } = $props();
let pending = $state(false);
const utcFormatter = new Intl.DateTimeFormat('en', {
dateStyle: 'medium',
timeStyle: 'medium',
hourCycle: 'h23',
timeZone: 'UTC'
});
function formatUtc(value: string): string {
return `${utcFormatter.format(new Date(value))} UTC`;
}
const checkWorker: SubmitFunction = () => {
pending = true;
return async ({ update }) => {
try {
await update({ reset: false });
} finally {
pending = false;
}
};
};
</script>
<AdminPage siteTitle={data.title} title="Health">
<section class="admin-info" aria-labelledby="application-heading">
<h2 id="application-heading">Application</h2>
<dl>
<dt>Version</dt>
<dd>{data.appVersion}</dd>
<dt>App URL</dt>
<dd>{data.appUrl}</dd>
<dt>Authentication</dt>
<dd>{data.authMode === 'oidc' ? 'OIDC' : 'Shared credentials'}</dd>
<dt>Redirect status code</dt>
<dd>{data.redirectStatusCode}</dd>
<dt>Forward query</dt>
<dd>{data.forwardQuery ? 'Yes' : 'No'}</dd>
<dt>Fallback destination</dt>
<dd>{data.fallbackDestination === 404 ? '404 Not found' : data.fallbackDestination}</dd>
</dl>
</section>
<section class="admin-info health-card" aria-labelledby="analytics-heading">
{#if data.retentionMonths !== 0 && (data.reportDelayed || data.reportDataAsOf === null)}
<aside class="health-result health-result-error" role="alert">
<span class="health-result-icon" aria-hidden="true"><IconWarningCircle /></span>
<div>
<p class="health-result-title">
{data.reportDelayed
? 'Analytics data is delayed.'
: 'Analytics reports are unavailable.'}
</p>
</div>
</aside>
{/if}
<div class="health-card-body">
<h2 id="analytics-heading">Analytics</h2>
<dl>
<dt>Collection</dt>
<dd>{data.retentionMonths === 0 ? 'Disabled' : 'Enabled'}</dd>
<dt>Retention</dt>
<dd>{data.retentionMonths === 0 ? 'Disabled' : `${data.retentionMonths} months`}</dd>
<dt>Report data as of</dt>
<dd>
{#if data.retentionMonths === 0}
Disabled
{:else if data.reportDataAsOf}
<time datetime={data.reportDataAsOf}>{formatUtc(data.reportDataAsOf)}</time>
{:else}
Unavailable
{/if}
</dd>
</dl>
</div>
</section>
<section class="admin-info health-card" aria-labelledby="worker-heading">
{#if !pending && form?.workerCheck}
<aside
class="health-result"
class:health-result-error={!form.workerCheck.reachable}
role="alert"
>
<span class="health-result-icon" aria-hidden="true">
{#if form.workerCheck.reachable}<IconCheckCircle />{:else}<IconWarningCircle />{/if}
</span>
<div>
<p class="health-result-title">
{form.workerCheck.reachable ? 'Worker is reachable.' : 'Worker could not be reached.'}
</p>
<p class="health-result-time">
Checked <time datetime={form.workerCheck.checkedAt}
>{formatUtc(form.workerCheck.checkedAt)}</time
>
</p>
</div>
</aside>
{/if}
<div class="health-card-body">
<h2 id="worker-heading">Worker</h2>
<dl>
<dt>Worker URL</dt>
<dd>{data.workerUrl}</dd>
</dl>
{#if pending || !form?.workerCheck}
<p class="worker-note" role="status">{pending ? 'Checking worker…' : 'Not checked'}</p>
{/if}
</div>
<div class="worker-actions">
<form
class="worker-form"
method="POST"
action="?/checkWorker"
use:enhance={checkWorker}
aria-busy={pending}
>
<button
type="submit"
class="ui-button worker-test-button"
class:busy={pending}
disabled={pending}
aria-label={pending ? 'Checking worker' : 'Test worker'}
>
<span class="button-label">Test worker</span>
{#if pending}<span class="worker-spinner" aria-hidden="true"><IconSpinner /></span>{/if}
</button>
</form>
</div>
</section>
</AdminPage>
<style>
.health-card {
padding: 0;
overflow: hidden;
}
.health-card-body {
padding: var(--space-5);
}
.worker-note {
margin-top: var(--space-5);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.health-result {
display: flex;
align-items: flex-start;
gap: var(--space-4);
padding: var(--space-5);
border-bottom: var(--border-width) solid var(--color-success-border);
background: var(--color-success-bg);
}
.health-result > div {
min-width: 0;
}
.health-result-error {
border-color: var(--color-danger-border);
background: var(--color-danger-bg);
}
.health-result-icon {
display: inline-flex;
flex-shrink: 0;
font-size: 1.5rem;
line-height: 1;
color: var(--color-success);
}
.health-result-error .health-result-icon {
color: var(--color-danger);
}
.health-result-title {
font-weight: 600;
}
.health-result-time {
overflow-wrap: anywhere;
margin-top: var(--space-1);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.worker-actions {
padding: var(--space-4) var(--space-5);
border-top: var(--border-width) solid var(--color-border-subtle);
background: var(--color-surface-muted);
}
.worker-form {
display: flex;
justify-content: flex-end;
}
.worker-test-button {
display: inline-grid;
grid-template-areas: 'content';
place-items: center;
border: var(--border-width) solid var(--color-border-strong);
background: var(--color-surface-card);
}
.worker-test-button > * {
grid-area: content;
}
.busy .button-label {
opacity: 0;
}
.worker-spinner {
display: inline-flex;
animation: worker-spin 0.9s linear infinite;
}
@keyframes worker-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.worker-spinner {
animation: none;
}
}
</style>
@@ -0,0 +1,164 @@
import packageJson from '../../../../../../package.json' with { type: 'json' };
import { createVisitPeriod, visitDays, type VisitDetails } from '@goto/analytics';
import { validateConfig } from '@goto/config';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { prepareSnapshot } from '#lib/server/snapshot.js';
import { readOverviewDetails } from '#lib/server/visit-reports.js';
import { isWorkerReachable } from '#lib/server/worker-health.js';
import { actions, load } from './+page.server.js';
vi.mock('#lib/server/visit-reports.js', () => ({ readOverviewDetails: vi.fn() }));
vi.mock('#lib/server/worker-health.js', () => ({ isWorkerReachable: vi.fn() }));
beforeEach(() => {
vi.resetAllMocks();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-06-15T13:00:00.000Z'));
});
afterEach(() => vi.useRealTimers());
function request(overrides: Record<string, unknown> = {}) {
const configuration = validateConfig(
{
url: { app: 'https://admin.example.test', worker: 'http://worker.example.test:9000/base' },
auth: {
secret: 'health-test-auth-secret-not-for-page-data',
credentials: { username: 'health-admin', password: 'private-health-password' }
},
database: { path: './private-visits.sqlite', worker_path: './private-worker.sqlite' },
domains: {},
...overrides
},
{ env: {} }
);
return {
locals: { snapshot: prepareSnapshot(configuration) },
request: new Request('https://admin.example.test/admin/health?/checkWorker', {
method: 'POST',
body: new URLSearchParams({ workerUrl: 'https://untrusted.example.test/forged' })
})
} as Parameters<typeof load>[0];
}
function emptyOverview(): VisitDetails {
const period = createVisitPeriod(3, Date.parse('2026-06-15T12:34:56.000Z'));
return {
version: 2,
scope: { kind: 'overview' },
period,
daily: visitDays(period).map((day) => ({ ...day, validVisits: 0, invalidVisits: 0 })),
browsers: [],
operatingSystems: []
};
}
test('returns only allowlisted settings and the prepared report cutoff, without probing', async () => {
const overview = emptyOverview();
vi.mocked(readOverviewDetails).mockResolvedValue({ overview, domains: [] });
const event = request();
const data = await load(event);
expect(data).toEqual({
appVersion: packageJson.version,
appUrl: 'https://admin.example.test',
authMode: 'credentials',
retentionMonths: 3,
reportDataAsOf: overview.period.asOf,
reportDelayed: false,
redirectStatusCode: 302,
forwardQuery: false,
fallbackDestination: 404,
workerUrl: 'http://worker.example.test:9000/base'
});
expect(readOverviewDetails).toHaveBeenCalledExactlyOnceWith(
event.locals.snapshot.configuration,
[]
);
expect(isWorkerReachable).not.toHaveBeenCalled();
});
test('projects OIDC mode and configured redirect defaults without exposing the provider settings', async () => {
vi.mocked(readOverviewDetails).mockResolvedValue(null);
const data = await load(
request({
auth: {
secret: 'health-test-auth-secret-not-for-page-data',
oidc: {
issuer: 'https://identity.example.test',
client_id: 'private-client-id',
client_secret: 'private-client-secret'
}
},
defaults: { status_code: 307, forward_query: true, fallback: 'https://example.test/missing' }
})
);
expect(data).toEqual({
appVersion: packageJson.version,
appUrl: 'https://admin.example.test',
authMode: 'oidc',
retentionMonths: 3,
reportDataAsOf: null,
reportDelayed: false,
redirectStatusCode: 307,
forwardQuery: true,
fallbackDestination: 'https://example.test/missing',
workerUrl: 'http://worker.example.test:9000/base'
});
});
test('marks reports delayed only after the hourly refresh grace period', async () => {
const overview = emptyOverview();
vi.mocked(readOverviewDetails).mockResolvedValue({ overview, domains: [] });
const graceDeadline = Date.parse(overview.period.asOf) + 75 * 60 * 1000;
vi.setSystemTime(new Date(graceDeadline));
expect((await load(request())).reportDelayed).toBe(false);
vi.setSystemTime(new Date(graceDeadline + 1));
expect((await load(request())).reportDelayed).toBe(true);
});
test('zero retention skips the report read and remains a normal read-only page', async () => {
const data = await load(request({ analytics: { retention_months: 0 } }));
expect(data.retentionMonths).toBe(0);
expect(data.reportDataAsOf).toBeNull();
expect(data.reportDelayed).toBe(false);
expect(data.appUrl).toBe('https://admin.example.test');
expect(readOverviewDetails).not.toHaveBeenCalled();
expect(isWorkerReachable).not.toHaveBeenCalled();
});
test('unavailable prepared analytics do not prevent viewing settings', async () => {
vi.mocked(readOverviewDetails).mockResolvedValue(null);
const data = await load(request());
expect(data.retentionMonths).toBe(3);
expect(data.reportDataAsOf).toBeNull();
expect(data.reportDelayed).toBe(false);
expect(data.workerUrl).toBe('http://worker.example.test:9000/base');
expect(isWorkerReachable).not.toHaveBeenCalled();
});
test('manual worker check uses the current configured address, ignoring submitted targets', async () => {
vi.mocked(readOverviewDetails).mockResolvedValue(null);
await load(request());
const event = request({
url: { app: 'https://admin.example.test', worker: 'http://current-worker.example.test:9001' }
}) as Parameters<typeof actions.checkWorker>[0];
vi.mocked(isWorkerReachable).mockResolvedValue(true);
const before = Date.now();
const result = await actions.checkWorker(event);
expect(isWorkerReachable).toHaveBeenCalledExactlyOnceWith(
'http://current-worker.example.test:9001'
);
expect(result).toEqual({ workerCheck: { reachable: true, checkedAt: expect.any(String) } });
expect(new Date(result.workerCheck.checkedAt).toISOString()).toBe(result.workerCheck.checkedAt);
expect(Date.parse(result.workerCheck.checkedAt)).toBeGreaterThanOrEqual(before);
expect(Date.parse(result.workerCheck.checkedAt)).toBeLessThanOrEqual(Date.now());
});
test('an unreachable worker returns only the negative result and completion timestamp', async () => {
vi.mocked(isWorkerReachable).mockResolvedValue(false);
const result = await actions.checkWorker(request() as Parameters<typeof actions.checkWorker>[0]);
expect(result).toEqual({ workerCheck: { reachable: false, checkedAt: expect.any(String) } });
expect(new Date(result.workerCheck.checkedAt).toISOString()).toBe(result.workerCheck.checkedAt);
expect(readOverviewDetails).not.toHaveBeenCalled();
});