diff --git a/.changeset/admin-overview.md b/.changeset/admin-overview.md new file mode 100644 index 0000000..566f1f1 --- /dev/null +++ b/.changeset/admin-overview.md @@ -0,0 +1,7 @@ +--- +'@goto/web': minor +'@goto/worker': minor +'@goto/cli': minor +--- + +Add an authenticated admin overview, domain summaries and dedicated link pages with current inventory and retained visits. One hourly background job, with scheduled jitter, prepares all reports in SQLite and coordinates retention cleanup. Admin pages read prepared snapshots directly and display their update time; no reporting API, token or worker URL is required. The job also prepares UTC daily, weekly and monthly series and browser/OS family counts for later visualization. Charts and the Health page are not included. diff --git a/.github/workflows/db.yml b/.github/workflows/db.yml index 7efd413..95cf9f3 100644 --- a/.github/workflows/db.yml +++ b/.github/workflows/db.yml @@ -9,6 +9,9 @@ on: - .github/workflows/** - .woodpecker/** - packages/db/** + - packages/config/** + - scripts/** + - tools/** pull_request: branches: [main] paths: @@ -17,6 +20,9 @@ on: - .github/workflows/** - .woodpecker/** - packages/db/** + - packages/config/** + - scripts/** + - tools/** permissions: contents: read @@ -48,3 +54,8 @@ jobs: run: pnpm --filter @goto/db lint - name: Unit tests run: pnpm --filter @goto/db test:coverage + - name: Development script checks + run: | + pnpm exec prettier --check scripts + pnpm exec eslint scripts + pnpm test:scripts diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 5911284..5e624b4 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -11,6 +11,7 @@ on: - .github/workflows/** - .woodpecker/** - apps/web/** + - apps/worker/** - packages/** pull_request: branches: [main] @@ -21,6 +22,7 @@ on: - .github/workflows/** - .woodpecker/** - apps/web/** + - apps/worker/** - packages/** permissions: diff --git a/.woodpecker/db.yml b/.woodpecker/db.yml index 547cab4..d338e62 100644 --- a/.woodpecker/db.yml +++ b/.woodpecker/db.yml @@ -12,6 +12,9 @@ when: - .github/workflows/** - .woodpecker/** - packages/db/** + - packages/config/** + - scripts/** + - tools/** - event: pull_request branch: main path: @@ -21,6 +24,9 @@ when: - .github/workflows/** - .woodpecker/** - packages/db/** + - packages/config/** + - scripts/** + - tools/** steps: prepare: @@ -52,6 +58,8 @@ steps: - corepack enable - pnpm --filter @goto/db check - pnpm --filter @goto/db lint + - pnpm exec prettier --check scripts + - pnpm exec eslint scripts test: image: node:26.5.0-bookworm @@ -61,3 +69,4 @@ steps: - npm install --global corepack@0.36.0 - corepack enable - pnpm --filter @goto/db test:coverage + - pnpm test:scripts diff --git a/.woodpecker/web.yml b/.woodpecker/web.yml index 169eaac..c87a6b4 100644 --- a/.woodpecker/web.yml +++ b/.woodpecker/web.yml @@ -13,6 +13,7 @@ when: - .github/workflows/** - .woodpecker/** - apps/web/** + - apps/worker/** - packages/** - event: pull_request path: @@ -23,6 +24,7 @@ when: - .github/workflows/** - .woodpecker/** - apps/web/** + - apps/worker/** - packages/** - event: tag ref: 'refs/tags/@goto/web@*' diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 24e1221..48131f3 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -20,10 +20,13 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml ./ RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm fetch --frozen-lockfile COPY . . RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm install --offline --frozen-lockfile +# Only generated output and caches are writable; source binds stay read-only. RUN mkdir -p /m/apps/web/config /m/apps/web/data /m/packages/config/dist /m/packages/db/dist /m/apps/web/.svelte-kit \ + '/m/apps/web/node_modules/$app' \ /m/apps/web/node_modules/.vite /m/apps/web/node_modules/.vite-temp \ && chown -R ${LOCAL_UID}:${LOCAL_GID} /home/app /m/apps/web/data /m/packages/config/dist /m/packages/db/dist \ - /m/apps/web/.svelte-kit /m/apps/web/node_modules/.vite /m/apps/web/node_modules/.vite-temp + /m/apps/web/.svelte-kit '/m/apps/web/node_modules/$app' \ + /m/apps/web/node_modules/.vite /m/apps/web/node_modules/.vite-temp USER ${LOCAL_UID}:${LOCAL_GID} WORKDIR /m/apps/web EXPOSE 5173 diff --git a/apps/web/e2e/auth.e2e.ts b/apps/web/e2e/auth.e2e.ts index 3c914a7..e4f7001 100644 --- a/apps/web/e2e/auth.e2e.ts +++ b/apps/web/e2e/auth.e2e.ts @@ -1,6 +1,8 @@ import { expect, test } from './support/test.ts'; -test('shared credentials protect the read-only inventory and sign-out removes access', async ({ +test.use({ withWorker: true }); + +test('shared credentials protect overview, domain and link pages and sign-out removes access', async ({ page, appServer }) => { @@ -13,16 +15,82 @@ test('shared credentials protect the read-only inventory and sign-out removes ac await page.getByLabel('Password', { exact: true }).fill('e2e-only-password'); await page.getByRole('button', { name: 'Sign in', exact: true }).click(); - await expect(page.getByRole('heading', { name: 'Configured links', exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Overview', exact: true })).toBeVisible(); + const visitsValue = page + .getByRole('region', { name: 'Valid visits', exact: true }) + .locator('.admin-stat-value'); + await expect(page.getByRole('region', { name: 'Domains', exact: true })).toContainText('2'); + await expect(visitsValue).toHaveText('3'); + const retentionValue = page + .getByRole('region', { name: 'Retention', exact: true }) + .locator('.admin-stat-value'); + await expect(retentionValue).toHaveText('3 months'); + await expect(page.locator('.admin-cards').getByRole('region')).toHaveCount(4); + const domainLink = page.getByRole('link', { name: 'go.example.org', exact: true }); + const domainRow = page.getByRole('row').filter({ has: domainLink }); + await expect(domainRow.getByRole('cell')).toHaveText(['6', '2']); + await domainLink.click(); + await expect(page.getByRole('heading', { name: 'go.example.org', exact: true })).toBeVisible(); + await expect(visitsValue).toHaveText('2'); + await expect(page.locator('.admin-cards .admin-stat-label')).toHaveText([ + 'Links', + 'Valid visits', + 'Invalid clicks', + 'Retention' + ]); await expect( - page - .getByRole('region', { name: 'Links on go.example.org', exact: true }) - .getByRole('link', { name: 'https://example.org/first-domain', exact: true }) + page.getByRole('region', { name: 'Invalid clicks', exact: true }).locator('.admin-stat-value') + ).toHaveText('1'); + const linksTable = page.getByRole('region', { name: 'Links on go.example.org', exact: true }); + const baseTable = page.getByRole('region', { name: 'Base URL on go.example.org', exact: true }); + const columns = ['Path', 'Destination', 'State', 'Visits']; + await expect(linksTable.getByRole('columnheader')).toHaveText(columns); + const historicalInvalid = linksTable + .getByRole('row') + .filter({ has: page.getByRole('link', { name: '/a/b', exact: true }) }); + await expect(historicalInvalid.getByRole('cell').last()).toHaveText('0'); + await expect(baseTable.getByRole('columnheader')).toHaveText(columns); + await expect(baseTable.getByRole('cell')).toHaveText(['404 Not found', 'Fallback', '0']); + await expect( + linksTable.getByRole('link', { name: 'https://example.org/first-domain', exact: true }) ).toBeVisible(); + const domainBreadcrumb = page + .getByRole('navigation', { name: 'Breadcrumb', exact: true }) + .getByRole('link', { name: 'go.example.org', exact: true }); + const docsPath = '/admin/domains/go.example.org/links/docs'; + await page.getByRole('link', { name: '/docs', exact: true }).click(); + await expect(page).toHaveURL(`${appServer.origin}${docsPath}`); + await expect(page.getByRole('heading', { name: '/docs', exact: true })).toBeVisible(); + await expect(visitsValue).toHaveText('1'); + await expect( + page.getByRole('region', { name: 'Links on go.example.org', exact: true }) + ).toHaveCount(0); + + await domainBreadcrumb.click(); + await page.getByRole('link', { name: '/a%2Fb', exact: true }).click(); + await expect(page.getByRole('heading', { name: '/a%2Fb', exact: true })).toBeVisible(); + await expect(page).toHaveURL(`${appServer.origin}/admin/domains/go.example.org/links/a%252Fb`); + await expect(visitsValue).toHaveText('1'); + await domainBreadcrumb.click(); + await page.getByRole('link', { name: '/a/b', exact: true }).click(); + await expect(page).toHaveURL(`${appServer.origin}/admin/domains/go.example.org/links/a/b`); + await expect(page.getByRole('heading', { name: '/a/b', exact: true })).toBeVisible(); + await expect(visitsValue).toHaveText('0'); + + await domainBreadcrumb.click(); + await baseTable.getByRole('link', { name: '/', exact: true }).click(); + await expect(page).toHaveURL(`${appServer.origin}/admin/domains/go.example.org/links`); + await expect(page.getByRole('heading', { name: '/', exact: true })).toBeVisible(); + await expect(visitsValue).toHaveText('0'); + await domainBreadcrumb.click(); + await expect(visitsValue).toHaveText('2'); + + const html = await page.content(); + await expect(html).not.toContain('e2e-only-password'); await page.getByRole('button', { name: 'Sign out', exact: true }).click(); await expect(page).toHaveURL(/\/signin\?/); - await page.goto(`${appServer.origin}/admin`); + await page.goto(`${appServer.origin}${docsPath}`); await expect(page).toHaveURL(/\/signin\?/); await expect(page.getByLabel('Username', { exact: true })).toBeVisible(); }); diff --git a/apps/web/e2e/server.mjs b/apps/web/e2e/server.mjs index 0385b38..51c23a4 100644 --- a/apps/web/e2e/server.mjs +++ b/apps/web/e2e/server.mjs @@ -1,11 +1,12 @@ import { mkdir, writeFile } from 'node:fs/promises'; +import { fork } from 'node:child_process'; +import { insertVisit, openDb, runMigrations } from '@goto/db'; import { createServer } from 'node:net'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; const directory = process.env.GOTO_E2E_BUILD_DIR; if (!directory) throw new Error('Missing E2E build directory'); - const runtimeDirectory = join(directory, `runtime-${process.pid}`); await mkdir(join(runtimeDirectory, 'config'), { recursive: true }); process.chdir(runtimeDirectory); @@ -22,27 +23,86 @@ await new Promise((resolve, reject) => reservation.close((error) => (error ? reject(error) : resolve())) ); process.env.PORT = String(address.port); -await writeFile( - join(runtimeDirectory, 'config/goto.yaml'), - JSON.stringify({ - site: { url: `http://localhost:${address.port}` }, - auth: { credentials: { username: 'example', password: 'e2e-only-password' } }, - domains: { - 'go.example.org': { - links: { - '/schema.json': { url: 'https://example.org/public-schema' }, - '/_app/version.json': { url: 'https://example.org/public-asset' }, - '/a/b': { url: 'https://example.org/decoded' }, - '/a%2Fb': { url: 'https://example.org/literal-percent' }, - '/docs': { url: 'https://example.org/first-domain' }, - '/healthz': { url: 'https://example.org/public-health' } - } - }, - 'tiny.example.org': { - links: { '/docs': { url: 'https://example.org/second-domain' } } +const configuration = { + site: { url: `http://localhost:${address.port}` }, + auth: { credentials: { username: 'example', password: 'e2e-only-password' } }, + domains: { + 'go.example.org': { + links: { + '/schema.json': { url: 'https://example.org/public-schema' }, + '/_app/version.json': { url: 'https://example.org/public-asset' }, + '/a/b': { url: 'https://example.org/decoded' }, + '/a%2Fb': { url: 'https://example.org/literal-percent' }, + '/docs': { url: 'https://example.org/first-domain' }, + '/healthz': { url: 'https://example.org/public-health' } } - } - }) -); - + }, + 'tiny.example.org': { links: { '/docs': { url: 'https://example.org/second-domain' } } } + } +}; +await writeFile(join(runtimeDirectory, 'config/goto.yaml'), JSON.stringify(configuration)); +if (process.env.GOTO_E2E_WORKER === '1') { + await seedVisits(); + await startWorker(); +} await import(pathToFileURL(join(directory, 'server.js')).href); + +async function seedVisits() { + const db = await openDb(join(runtimeDirectory, 'data/goto.sqlite')); + try { + await runMigrations(db); + const common = { + visited_at: Date.now() - 1000, + ip: '203.0.113.1', + user_agent: null, + accept_language: null, + browser: 'Other', + os: 'Other' + }; + const visits = [ + { domain: 'go.example.org', path: '/docs', outcome: 'valid' }, + { domain: 'go.example.org', path: '/a/b', outcome: 'invalid' }, + { domain: 'go.example.org', path: '/', outcome: 'invalid' }, + { domain: 'go.example.org', path: '/a%2Fb', outcome: 'valid' }, + { domain: 'tiny.example.org', path: '/docs', outcome: 'valid' } + ]; + for (const visit of visits) await insertVisit(db, { ...common, ...visit }); + } finally { + await db.destroy(); + } +} + +async function startWorker() { + const worker = fork(new URL('../../worker/dist/index.js', import.meta.url), [], { + cwd: runtimeDirectory, + env: { ...process.env, HOST: '127.0.0.1', PORT: '0', SHUTDOWN_TIMEOUT: '5' }, + execArgv: [], + stdio: ['ignore', 'inherit', 'inherit', 'ipc'] + }); + process.on('SIGTERM', () => worker.kill('SIGTERM')); + process.on('SIGINT', () => worker.kill('SIGTERM')); + process.on('disconnect', () => worker.kill('SIGTERM')); + process.once('exit', () => worker.kill('SIGKILL')); + worker.once('exit', (code, signal) => { + if (code !== 0 || signal !== null) process.exitCode = 1; + }); + const ready = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + worker.kill('SIGKILL'); + reject(new Error('E2E worker startup timed out')); + }, 15_000); + worker.once('message', (message) => { + clearTimeout(timer); + resolve(message); + }); + worker.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + worker.once('exit', () => { + clearTimeout(timer); + reject(new Error('E2E worker exited before readiness')); + }); + }); + if (ready.type !== 'ready') throw new Error('Invalid worker readiness'); +} diff --git a/apps/web/e2e/support/test.ts b/apps/web/e2e/support/test.ts index a3a1814..7546b55 100644 --- a/apps/web/e2e/support/test.ts +++ b/apps/web/e2e/support/test.ts @@ -53,13 +53,17 @@ async function stopServer(child: ChildProcess): Promise { } } -export const test = base.extend<{ appServer: AppServer }>({ - // eslint-disable-next-line no-empty-pattern -- Playwright discovers dependencies by destructuring. - appServer: async ({}, use, testInfo) => { +export const test = base.extend<{ appServer: AppServer; withWorker: boolean }>({ + withWorker: [false, { option: true }], + appServer: async ({ withWorker }, use, testInfo) => { const directory = process.env.GOTO_E2E_BUILD_DIR; if (!directory) throw new Error('Missing E2E build directory'); const child = fork(new URL('../server.mjs', import.meta.url), [], { - env: { ...serverEnvironment(), GOTO_E2E_BUILD_DIR: directory }, + env: { + ...serverEnvironment(), + GOTO_E2E_BUILD_DIR: directory, + GOTO_E2E_WORKER: withWorker ? '1' : '0' + }, execArgv: [], stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }); diff --git a/apps/web/package.json b/apps/web/package.json index a27dc91..7ef8ae0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,7 +21,7 @@ "format": "prettier --ignore-path ../../.prettierignore --write .", "test": "vitest run", "test:coverage": "vitest run --coverage", - "test:e2e": "playwright test" + "test:e2e": "pnpm --filter @goto/worker... build && playwright test" }, "dependencies": { "@goto/config": "workspace:*", diff --git a/apps/web/src/lib/components/AdminNav.svelte b/apps/web/src/lib/components/admin/AdminNav.svelte similarity index 94% rename from apps/web/src/lib/components/AdminNav.svelte rename to apps/web/src/lib/components/admin/AdminNav.svelte index 4c98480..a27a8c3 100644 --- a/apps/web/src/lib/components/AdminNav.svelte +++ b/apps/web/src/lib/components/admin/AdminNav.svelte @@ -2,7 +2,7 @@ import { page } from '$app/state'; import { goto } from '$app/navigation'; import { authClient } from '#lib/auth-client.js'; - import ThemeToggle from './ThemeToggle.svelte'; + import ThemeToggle from '../ThemeToggle.svelte'; let { title }: { title: string } = $props(); @@ -49,7 +49,8 @@