diff --git a/src/lib/ui/audit.ts b/src/lib/ui/audit.ts new file mode 100644 index 0000000..52ce3e4 --- /dev/null +++ b/src/lib/ui/audit.ts @@ -0,0 +1,100 @@ +interface AuditEntry { + action: string; + targetId: string | null; + details: string; +} + +const actionLabels: Record = { + setup: 'Administrator created', + login: 'Signed in', + 'login.failed': 'Sign-in failed', + logout: 'Signed out', + 'sessions.revoked': 'Sessions ended', + 'user.created': 'User created', + 'user.updated': 'Access updated', + 'password.changed': 'Password changed', + request: 'Action requested' +}; + +const requestActions: Record = { + 'POST /api/configs/inspect': 'Inspect config', + 'POST /api/scripts/inspect': 'Inspect script', + 'POST /api/servers': 'Create server', + 'DELETE /api/servers/[name]': 'Remove server', + 'POST /api/servers/[name]/probe-rcon': 'Check server connection', + 'POST /api/users': 'Create user', + 'PATCH /api/users/[id]': 'Update user access', + 'POST /api/users/[id]/revoke-sessions': 'Sign out sessions', + 'POST /api/auth/logout': 'Sign out', + 'POST /api/auth/password': 'Change password', + 'PUT /api/settings': 'Save settings', + 'PATCH /api/settings': 'Save settings', + 'POST /api/settings/retention/preview': 'Preview data cleanup', + 'POST /api/plugins/github/import': 'Import plugin', + 'PUT /api/server-keys': 'Save server key', + 'DELETE /api/server-keys': 'Remove server key' +}; + +const requestAreas: Record = { + auth: 'account', + autoscaling: 'autoscaling', + bank: 'bank', + betas: 'beta', + blueprints: 'blueprint', + campaigns: 'campaign', + configs: 'config', + 'deployment-collections': 'server collection', + deployments: 'campaign', + 'game-files': 'game files', + hosts: 'host', + maintenance: 'maintenance', + moderation: 'moderation', + plugins: 'plugin', + plutonium: 'game update', + provisioning: 'server operation', + rebalancing: 'rebalancing', + scripts: 'script', + 'server-keys': 'server key', + servers: 'server', + settings: 'settings', + users: 'user', + watchdog: 'recovery' +}; + +export function auditPresentation(event: AuditEntry): { + label: string; + requestAction: string | null; + details: string; +} { + let parsed: unknown; + try { + parsed = JSON.parse(event.details); + } catch { + parsed = null; + } + const fields = + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null; + const request = event.action === 'request'; + const method = typeof fields?.method === 'string' ? fields.method.toUpperCase() : ''; + const route = event.targetId ?? (typeof fields?.path === 'string' ? fields.path : ''); + const area = route.startsWith('/api/') ? requestAreas[route.split('/')[2]] : undefined; + // Older entries only recorded the route and HTTP method, so body-driven + // actions cannot safely be inferred more precisely than their feature area. + const requestAction = request + ? (requestActions[`${method} ${route}`] ?? (area ? `Manage ${area}` : 'Other action')) + : null; + const details = request + ? JSON.stringify( + { ...(fields ?? { recordedDetails: event.details }), routeId: event.targetId }, + null, + 2 + ) + : fields + ? Object.keys(fields).length + ? JSON.stringify(fields, null, 2) + : '' + : event.details; + return { label: actionLabels[event.action] ?? event.action, requestAction, details }; +} diff --git a/src/lib/ui/campaigns.ts b/src/lib/ui/campaigns.ts index 6a40bb5..53eac5f 100644 --- a/src/lib/ui/campaigns.ts +++ b/src/lib/ui/campaigns.ts @@ -19,11 +19,21 @@ export interface CampaignSummary extends CampaignScriptSummary { servers: { restart_state: string; server_deleted_at?: string | null }[]; } +export function campaignActionLabel(action: string): string { + switch (action) { + case 'drift': + return 'Maintenance repair'; + case 'promote': + return 'Promote'; + case 'rollback': + return 'Rollback'; + default: + return 'Update'; + } +} + export function campaignTitle(campaign: CampaignSummary): string { - const action = - campaign.action === 'drift' - ? 'Maintenance repair' - : campaign.action.charAt(0).toUpperCase() + campaign.action.slice(1); + const action = campaignActionLabel(campaign.action); if (campaign.action === 'drift') return action; if (campaign.action === 'rollback' && campaign.beta_id) return `${action} · Beta #${campaign.beta_id}`; diff --git a/src/routes/campaigns/[id]/+page.svelte b/src/routes/campaigns/[id]/+page.svelte index 2e7ad4b..2271c11 100644 --- a/src/routes/campaigns/[id]/+page.svelte +++ b/src/routes/campaigns/[id]/+page.svelte @@ -19,7 +19,11 @@ import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import OperationHistory from '$ui/components/OperationHistory.svelte'; import type { ActivityEvent } from '$lib/operation-flow.js'; - import { campaignServerStatus, type CampaignServerGroup } from '$ui/campaigns.js'; + import { + campaignActionLabel, + campaignServerStatus, + type CampaignServerGroup + } from '$ui/campaigns.js'; import { serverIdentity } from '$ui/server-labels.js'; interface FileRow { @@ -86,6 +90,9 @@ let filter = $state<'all' | CampaignServerGroup>('all'); let expandedServers = $state([]); let copyStatus = $state(''); + const title = $derived( + detail ? `${campaignActionLabel(detail.deployment.action)} campaign` : 'Campaign' + ); const serverRows = $derived( (detail?.servers ?? []).map((server) => ({ server, @@ -275,12 +282,7 @@ } -{detail ? `${detail.deployment.action} campaign · ${detail.deployment.id}` : 'Campaign'} | Electric - Cherry +{title} | Electric Cherry {#if loadError}{/if} @@ -288,10 +290,10 @@ {#if detail} {@const action = detail.deployment.action}
- + {#snippet crumbs()} {/snippet} {#snippet actions()} diff --git a/src/routes/maintenance/+page.svelte b/src/routes/maintenance/+page.svelte index cfb0249..4b67d62 100644 --- a/src/routes/maintenance/+page.svelte +++ b/src/routes/maintenance/+page.svelte @@ -542,10 +542,15 @@ >{/if}

- Published revision: {data.runtime.release.revision ? `r${data.runtime.release.revision}` - : 'Unknown'} ·

{#if data.runtime.release.checkedAt}

- Checked -

{/if} - {#if data.runtime.release.error}{/if} + {#if data.runtime.release.error} + +
+ Technical details +
{data.runtime.release.error}
+
+ {/if}

Plutainer applies the available release at startup when auto-update is enabled. Maintenance verifies the running revision after recovery. diff --git a/src/routes/maintenance/[id]/+page.svelte b/src/routes/maintenance/[id]/+page.svelte index 0c835b5..2d42612 100644 --- a/src/routes/maintenance/[id]/+page.svelte +++ b/src/routes/maintenance/[id]/+page.svelte @@ -16,6 +16,7 @@ import ListFooter from '$ui/components/primitives/ListFooter.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import ProvisioningStatusBadge from '$ui/components/ProvisioningStatusBadge.svelte'; + import CopyValue from '$ui/components/CopyValue.svelte'; let detail = $state(null); let loadError = $state(''); @@ -160,7 +161,6 @@ title={incident ? `${incident.kind === 'drift' ? 'File drift' : 'Server health'} · ${serverTitle(incident.server)}` : 'Maintenance finding'} - subtitle={incident?.id} > {#snippet crumbs()}Open server logs {/if} +

+ Technical details +
+
+
Incident ID
+
+
+
+
diff --git a/src/routes/settings/users/+page.svelte b/src/routes/settings/users/+page.svelte index 8322e57..fe3213a 100644 --- a/src/routes/settings/users/+page.svelte +++ b/src/routes/settings/users/+page.svelte @@ -8,6 +8,7 @@ import { invalidateAll } from '$app/navigation'; import type { AuthUser, UserRole } from '$lib/auth.js'; import { confirmAction } from '$ui/confirm.js'; + import { auditPresentation } from '$ui/audit.js'; import Button from '$ui/components/primitives/Button.svelte'; import Dialog from '$ui/components/primitives/Dialog.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; @@ -101,24 +102,6 @@ auditLoading = false; } } - const actionLabels: Record = { - setup: 'Administrator created', - login: 'Signed in', - 'login.failed': 'Sign-in failed', - logout: 'Signed out', - 'sessions.revoked': 'Sessions ended', - 'user.created': 'User created', - 'user.updated': 'Access updated', - 'password.changed': 'Password changed' - }; - function auditDetails(value: string): string { - try { - const parsed = JSON.parse(value); - return Object.keys(parsed).length ? JSON.stringify(parsed, null, 2) : ''; - } catch { - return value; - } - } onMount(() => { if (canViewUsers) void Promise.all([load(), loadAudit()]); else { @@ -339,7 +322,7 @@

Account activity

-

The latest 200 sign-in and account events.

+

The latest 200 sign-in, account, and action events.

TimeEventByAccountTimeEventByAccount or actionDetails - {#each events as event (event.id)}{@const details = auditDetails( - event.details - )}{actionLabels[event.action] ?? event.action}{presentation.label}{event.actor ? `@${event.actor}` : 'Unauthenticated'}{event.targetId - ? (users.find((user) => user.id === event.targetId) - ?.username ?? event.targetId) - : '—'}{presentation.requestAction ?? + (event.targetId + ? (users.find((user) => user.id === event.targetId) + ?.username ?? event.targetId) + : '—')}{#if details}
- View details -
{details}
+ >{#if presentation.details}
+ Technical details +
{presentation.details}
{:else}—{/if}{/each} diff --git a/tests/client/maintenance-release.test.ts b/tests/client/maintenance-release.test.ts new file mode 100644 index 0000000..4965101 --- /dev/null +++ b/tests/client/maintenance-release.test.ts @@ -0,0 +1,117 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { flushSync, mount, unmount } from 'svelte'; +import { writable } from 'svelte/store'; +import MaintenancePage from '../../src/routes/maintenance/+page.svelte'; +import type { MaintenanceOverview } from '../../src/lib/maintenance.js'; +import type { PlutoniumRelease } from '../../src/lib/plutonium.js'; +import type { UserRole } from '../../src/lib/auth.js'; + +const currentPage = writable({ + url: new URL('http://localhost/maintenance'), + data: { user: { role: 'admin' as UserRole } } +}); +vi.mock('$app/stores', () => ({ + page: { + subscribe: (run: Parameters[0]) => currentPage.subscribe(run) + } +})); +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); +vi.mock('../../src/lib/ui/poll.js', () => ({ + pollVisible: (task: () => Promise) => { + void task(); + return () => {}; + } +})); + +let component: ReturnType | undefined; +afterEach(async () => { + if (component) await unmount(component); + component = undefined; + document.body.replaceChildren(); + vi.unstubAllGlobals(); +}); + +function overview(release: Partial = {}): MaintenanceOverview { + return { + policies: { paused: false, health: true, drift: true, updates: false }, + recovery: { enabled: true, lastScanAt: null, scanError: null, incidents: [] }, + runtime: { + release: { + revision: null, + checkedAt: '2026-10-17T12:00:00Z', + error: 'Updater manifest request failed (HTTP 503).', + ...release + }, + schedule: { enabled: false, time: '06:00', timeZone: 'UTC', nextAt: null }, + servers: [], + jobs: [] + } + }; +} +async function show(role: UserRole = 'admin') { + currentPage.update((value) => ({ ...value, data: { user: { role } } })); + component = mount(MaintenancePage, { target: document.body }); + flushSync(); + await vi.waitFor(() => expect(document.querySelector('#updates')).not.toBeNull()); + return document.querySelector('#updates')!; +} +const button = (panel: HTMLElement, label: string) => + [...panel.querySelectorAll('button')].find((node) => node.textContent?.trim() === label); + +it('shows unavailable release information on the first failed check and keeps diagnostics closed', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json(overview()))); + const panel = await show(); + const alert = panel.querySelector('[role="alert"]')!; + expect(alert.textContent).toContain('Could not refresh the published Plutonium release.'); + expect(alert.textContent).toContain('The published revision is unavailable.'); + expect(alert.textContent).toContain('No previous release information is available.'); + expect(panel.querySelector('p strong')?.textContent).toBe('Unavailable'); + expect(panel.textContent).not.toMatch(/cached|Showing the last known release/); + const details = panel.querySelector('details')!; + expect(details.open).toBe(false); + expect(details.textContent).toContain('Updater manifest request failed (HTTP 503).'); + const primary = panel.cloneNode(true) as HTMLElement; + primary.querySelectorAll('details').forEach((node) => node.remove()); + expect(primary.textContent).not.toContain('HTTP 503'); + expect(panel.textContent).toContain('Last attempted'); +}); + +it('labels a retained revision honestly and clears the failure after retry succeeds', async () => { + let resolve!: (response: Response) => void; + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json(overview({ revision: 5354 }))) + .mockImplementationOnce(() => new Promise((done) => (resolve = done))); + vi.stubGlobal('fetch', fetch); + const panel = await show(); + expect(panel.textContent).toContain('Last known published revision'); + expect(panel.textContent).toContain('r5354'); + expect(panel.querySelector('[role="alert"]')!.textContent).toContain( + 'Showing the last known release; it may be out of date.' + ); + button(panel, 'Retry checks')!.click(); + flushSync(); + expect(fetch).toHaveBeenLastCalledWith('/api/maintenance', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'scan' }) + }); + expect(button(panel, 'Retry checks')!.disabled).toBe(true); + resolve( + Response.json({ ...overview({ revision: 5355, error: null }), notice: 'Checks finished.' }) + ); + await vi.waitFor(() => expect(panel.querySelector('[role="alert"]')).toBeNull()); + expect(panel.textContent).toContain('r5355'); + expect(panel.textContent).toContain('Checked'); + expect(panel.textContent).not.toMatch(/Last known|Last attempted|HTTP 503/); +}); + +it('keeps release failure details available without offering an administrator check to viewers', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json(overview()))); + const panel = await show('viewer'); + expect(panel.querySelector('[role="alert"]')!.textContent).toContain( + 'Could not refresh the published Plutonium release.' + ); + expect(panel.querySelector('details')?.textContent).toContain('HTTP 503'); + expect(button(panel, 'Retry checks')).toBeUndefined(); +}); diff --git a/tests/client/user-audit.test.ts b/tests/client/user-audit.test.ts new file mode 100644 index 0000000..4d44296 --- /dev/null +++ b/tests/client/user-audit.test.ts @@ -0,0 +1,95 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { flushSync, mount, unmount } from 'svelte'; +import { writable } from 'svelte/store'; +import UsersPage from '../../src/routes/settings/users/+page.svelte'; + +const page = writable({ + data: { user: { id: 'admin', username: 'administrator', role: 'admin' } } +}); +vi.mock('$app/stores', () => ({ + page: { subscribe: (run: Parameters[0]) => page.subscribe(run) } +})); +vi.mock('$app/navigation', () => ({ invalidateAll: vi.fn() })); + +let component: ReturnType | undefined; +afterEach(async () => { + if (component) await unmount(component); + component = undefined; + document.body.replaceChildren(); + vi.unstubAllGlobals(); +}); + +it('describes requests without presenting routes as accounts and preserves their diagnostic evidence', async () => { + const events = [ + { + action: 'request', + targetId: '/api/configs/inspect', + details: JSON.stringify({ method: 'POST', status: 200, path: '/api/configs/inspect' }) + }, + { + action: 'request', + targetId: '/api/servers/[name]', + details: JSON.stringify({ method: 'DELETE', status: 202, path: '/api/servers/demo' }) + }, + { action: 'request', targetId: '/api/provisioning', details: '{old malformed details' }, + { action: 'request', targetId: '/api/future/action', details: 'null' }, + { action: 'login', targetId: 'admin', details: '{}' } + ].map((event, index) => ({ + ...event, + id: index + 1, + actorId: 'admin', + actor: 'administrator', + createdAt: Date.now() + })); + vi.stubGlobal( + 'fetch', + vi.fn(async (path: string) => + Response.json( + path.endsWith('/audit') + ? { events } + : { + users: [ + { + id: 'admin', + username: 'administrator', + displayName: 'Administrator', + role: 'admin', + disabled: false, + mustChangePassword: false, + lastLoginAt: null + } + ] + } + ) + ) + ); + component = mount(UsersPage, { target: document.body }); + flushSync(); + await vi.waitFor(() => expect(document.querySelectorAll('tbody tr')).toHaveLength(5)); + const rows = Array.from(document.querySelectorAll('tbody tr')); + expect(rows.map((row) => row.cells[1].textContent)).toEqual([ + 'Action requested', + 'Action requested', + 'Action requested', + 'Action requested', + 'Signed in' + ]); + expect(rows.map((row) => row.cells[3].textContent)).toEqual([ + 'Inspect config', + 'Remove server', + 'Manage server operation', + 'Other action', + 'administrator' + ]); + expect(document.querySelector('thead')?.textContent).toContain('Account or action'); + expect(rows[1].querySelector('details')?.open).toBe(false); + expect(rows[1].querySelector('summary')?.textContent).toBe('Technical details'); + expect(JSON.parse(rows[1].querySelector('pre')!.textContent!)).toEqual({ + method: 'DELETE', + status: 202, + path: '/api/servers/demo', + routeId: '/api/servers/[name]' + }); + expect(rows[2].querySelector('pre')?.textContent).toContain('{old malformed details'); + expect(rows[3].querySelector('pre')?.textContent).toContain('/api/future/action'); +});