diff --git a/.changeset/admin-health.md b/.changeset/admin-health.md
new file mode 100644
index 0000000..ef40f94
--- /dev/null
+++ b/.changeset/admin-health.md
@@ -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.
diff --git a/apps/web/e2e/auth.e2e.ts b/apps/web/e2e/auth.e2e.ts
index 56e9538..d700eb8 100644
--- a/apps/web/e2e/auth.e2e.ts
+++ b/apps/web/e2e/auth.e2e.ts
@@ -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');
});
});
diff --git a/apps/web/e2e/server.mjs b/apps/web/e2e/server.mjs
index d819b2b..b493f27 100644
--- a/apps/web/e2e/server.mjs
+++ b/apps/web/e2e/server.mjs
@@ -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));
}
diff --git a/apps/web/src/lib/components/admin/AdminNav.svelte b/apps/web/src/lib/components/admin/AdminNav.svelte
index 15824fb..2278c75 100644
--- a/apps/web/src/lib/components/admin/AdminNav.svelte
+++ b/apps/web/src/lib/components/admin/AdminNav.svelte
@@ -52,6 +52,10 @@
{title}
+ Health
@@ -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;
diff --git a/apps/web/src/lib/server/worker-health.test.ts b/apps/web/src/lib/server/worker-health.test.ts
new file mode 100644
index 0000000..7b9790b
--- /dev/null
+++ b/apps/web/src/lib/server/worker-health.test.ts
@@ -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);
+});
diff --git a/apps/web/src/lib/server/worker-health.ts b/apps/web/src/lib/server/worker-health.ts
new file mode 100644
index 0000000..ec3fcce
--- /dev/null
+++ b/apps/web/src/lib/server/worker-health.ts
@@ -0,0 +1,13 @@
+const HEALTH_TIMEOUT_MS = 3_000;
+
+export async function isWorkerReachable(workerBaseUrl: string): Promise
{
+ try {
+ const response = await fetch(`${workerBaseUrl.replace(/\/+$/, '')}/healthz`, {
+ signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS)
+ });
+ await response.body?.cancel();
+ return response.ok;
+ } catch {
+ return false;
+ }
+}
diff --git a/apps/web/src/lib/styles/theme.css b/apps/web/src/lib/styles/theme.css
index 20154c2..177729a 100644
--- a/apps/web/src/lib/styles/theme.css
+++ b/apps/web/src/lib/styles/theme.css
@@ -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);
diff --git a/apps/web/src/routes/(admin)/(auth)/admin/health/+page.server.ts b/apps/web/src/routes/(admin)/(auth)/admin/health/+page.server.ts
new file mode 100644
index 0000000..b20b5c4
--- /dev/null
+++ b/apps/web/src/routes/(admin)/(auth)/admin/health/+page.server.ts
@@ -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;
diff --git a/apps/web/src/routes/(admin)/(auth)/admin/health/+page.svelte b/apps/web/src/routes/(admin)/(auth)/admin/health/+page.svelte
new file mode 100644
index 0000000..27670f1
--- /dev/null
+++ b/apps/web/src/routes/(admin)/(auth)/admin/health/+page.svelte
@@ -0,0 +1,240 @@
+
+
+
+
+ Application
+
+ - Version
+ - {data.appVersion}
+ - App URL
+ - {data.appUrl}
+ - Authentication
+ - {data.authMode === 'oidc' ? 'OIDC' : 'Shared credentials'}
+ - Redirect status code
+ - {data.redirectStatusCode}
+ - Forward query
+ - {data.forwardQuery ? 'Yes' : 'No'}
+ - Fallback destination
+ - {data.fallbackDestination === 404 ? '404 Not found' : data.fallbackDestination}
+
+
+
+ {#if data.retentionMonths !== 0 && (data.reportDelayed || data.reportDataAsOf === null)}
+
+ {/if}
+
+
Analytics
+
+ - Collection
+ - {data.retentionMonths === 0 ? 'Disabled' : 'Enabled'}
+ - Retention
+ - {data.retentionMonths === 0 ? 'Disabled' : `${data.retentionMonths} months`}
+ - Report data as of
+ -
+ {#if data.retentionMonths === 0}
+ Disabled
+ {:else if data.reportDataAsOf}
+
+ {:else}
+ Unavailable
+ {/if}
+
+
+
+
+
+ {#if !pending && form?.workerCheck}
+
+ {/if}
+
+
Worker
+
+ - Worker URL
+ - {data.workerUrl}
+
+ {#if pending || !form?.workerCheck}
+
{pending ? 'Checking worker…' : 'Not checked'}
+ {/if}
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(admin)/(auth)/admin/health/page.server.test.ts b/apps/web/src/routes/(admin)/(auth)/admin/health/page.server.test.ts
new file mode 100644
index 0000000..a770138
--- /dev/null
+++ b/apps/web/src/routes/(admin)/(auth)/admin/health/page.server.test.ts
@@ -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 = {}) {
+ 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[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[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[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();
+});