Use friendly UI labels and release feedback (Refs #120)
CI / checks (push) Successful in 6m39s
CI / checks (push) Successful in 6m39s
This commit is contained in:
@@ -0,0 +1,100 @@
|
|||||||
|
interface AuditEntry {
|
||||||
|
action: string;
|
||||||
|
targetId: string | null;
|
||||||
|
details: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionLabels: Record<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
'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<string, string> = {
|
||||||
|
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<string, unknown>)
|
||||||
|
: 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 };
|
||||||
|
}
|
||||||
+14
-4
@@ -19,11 +19,21 @@ export interface CampaignSummary extends CampaignScriptSummary {
|
|||||||
servers: { restart_state: string; server_deleted_at?: string | null }[];
|
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 {
|
export function campaignTitle(campaign: CampaignSummary): string {
|
||||||
const action =
|
const action = campaignActionLabel(campaign.action);
|
||||||
campaign.action === 'drift'
|
|
||||||
? 'Maintenance repair'
|
|
||||||
: campaign.action.charAt(0).toUpperCase() + campaign.action.slice(1);
|
|
||||||
if (campaign.action === 'drift') return action;
|
if (campaign.action === 'drift') return action;
|
||||||
if (campaign.action === 'rollback' && campaign.beta_id)
|
if (campaign.action === 'rollback' && campaign.beta_id)
|
||||||
return `${action} · Beta #${campaign.beta_id}`;
|
return `${action} · Beta #${campaign.beta_id}`;
|
||||||
|
|||||||
@@ -19,7 +19,11 @@
|
|||||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||||
import OperationHistory from '$ui/components/OperationHistory.svelte';
|
import OperationHistory from '$ui/components/OperationHistory.svelte';
|
||||||
import type { ActivityEvent } from '$lib/operation-flow.js';
|
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';
|
import { serverIdentity } from '$ui/server-labels.js';
|
||||||
|
|
||||||
interface FileRow {
|
interface FileRow {
|
||||||
@@ -86,6 +90,9 @@
|
|||||||
let filter = $state<'all' | CampaignServerGroup>('all');
|
let filter = $state<'all' | CampaignServerGroup>('all');
|
||||||
let expandedServers = $state<string[]>([]);
|
let expandedServers = $state<string[]>([]);
|
||||||
let copyStatus = $state('');
|
let copyStatus = $state('');
|
||||||
|
const title = $derived(
|
||||||
|
detail ? `${campaignActionLabel(detail.deployment.action)} campaign` : 'Campaign'
|
||||||
|
);
|
||||||
const serverRows = $derived(
|
const serverRows = $derived(
|
||||||
(detail?.servers ?? []).map((server) => ({
|
(detail?.servers ?? []).map((server) => ({
|
||||||
server,
|
server,
|
||||||
@@ -275,12 +282,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head
|
<svelte:head><title>{title} | Electric Cherry</title></svelte:head>
|
||||||
><title
|
|
||||||
>{detail ? `${detail.deployment.action} campaign · ${detail.deployment.id}` : 'Campaign'} | Electric
|
|
||||||
Cherry</title
|
|
||||||
></svelte:head
|
|
||||||
>
|
|
||||||
{#if loadError}<p role="alert" class="err">
|
{#if loadError}<p role="alert" class="err">
|
||||||
{loadError} <button onclick={loadDetail}>Retry</button>
|
{loadError} <button onclick={loadDetail}>Retry</button>
|
||||||
</p>{/if}
|
</p>{/if}
|
||||||
@@ -288,10 +290,10 @@
|
|||||||
{#if detail}
|
{#if detail}
|
||||||
{@const action = detail.deployment.action}
|
{@const action = detail.deployment.action}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<SectionHeader title={`${action.charAt(0).toUpperCase() + action.slice(1)} campaign`}>
|
<SectionHeader {title}>
|
||||||
{#snippet crumbs()}
|
{#snippet crumbs()}
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[{ label: 'Campaigns', href: '/campaigns' }, { label: action }]}
|
items={[{ label: 'Campaigns', href: '/campaigns' }, { label: title }]}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
|
|||||||
@@ -542,10 +542,15 @@
|
|||||||
>{/if}
|
>{/if}
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
Published revision: <strong
|
{data.runtime.release.error && data.runtime.release.revision !== null
|
||||||
|
? 'Last known published revision'
|
||||||
|
: 'Published revision'}:
|
||||||
|
<strong
|
||||||
>{data.runtime.release.revision
|
>{data.runtime.release.revision
|
||||||
? `r${data.runtime.release.revision}`
|
? `r${data.runtime.release.revision}`
|
||||||
: 'Unknown'}</strong
|
: data.runtime.release.error
|
||||||
|
? 'Unavailable'
|
||||||
|
: 'Not recorded'}</strong
|
||||||
>
|
>
|
||||||
·
|
·
|
||||||
<a href={PLUTONIUM_MANIFEST_URL} target="_blank" rel="noreferrer"
|
<a href={PLUTONIUM_MANIFEST_URL} target="_blank" rel="noreferrer"
|
||||||
@@ -559,11 +564,26 @@
|
|||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
{#if data.runtime.release.checkedAt}<p class="muted">
|
{#if data.runtime.release.checkedAt}<p class="muted">
|
||||||
Checked <RelativeTime value={data.runtime.release.checkedAt} />
|
{data.runtime.release.error ? 'Last attempted' : 'Checked'}
|
||||||
</p>{/if}
|
<RelativeTime value={data.runtime.release.checkedAt} />
|
||||||
{#if data.runtime.release.error}<p class="ui-error" role="alert">
|
|
||||||
{data.runtime.release.error} Displayed release information is cached.
|
|
||||||
</p>{/if}
|
</p>{/if}
|
||||||
|
{#if data.runtime.release.error}
|
||||||
|
<p class="ui-error" role="alert">
|
||||||
|
Could not refresh the published Plutonium release.
|
||||||
|
{data.runtime.release.revision !== null
|
||||||
|
? 'Showing the last known release; it may be out of date.'
|
||||||
|
: 'The published revision is unavailable. No previous release information is available.'}
|
||||||
|
{#if administrator}<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={() => act({ action: 'scan' })}>Retry checks</Button
|
||||||
|
>{/if}
|
||||||
|
</p>
|
||||||
|
<details>
|
||||||
|
<summary>Technical details</summary>
|
||||||
|
<pre>{data.runtime.release.error}</pre>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Plutainer applies the available release at startup when auto-update is
|
Plutainer applies the available release at startup when auto-update is
|
||||||
enabled. Maintenance verifies the running revision after recovery.
|
enabled. Maintenance verifies the running revision after recovery.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
import ProvisioningStatusBadge from '$ui/components/ProvisioningStatusBadge.svelte';
|
import ProvisioningStatusBadge from '$ui/components/ProvisioningStatusBadge.svelte';
|
||||||
|
import CopyValue from '$ui/components/CopyValue.svelte';
|
||||||
|
|
||||||
let detail = $state<WatchdogDetail | null>(null);
|
let detail = $state<WatchdogDetail | null>(null);
|
||||||
let loadError = $state('');
|
let loadError = $state('');
|
||||||
@@ -160,7 +161,6 @@
|
|||||||
title={incident
|
title={incident
|
||||||
? `${incident.kind === 'drift' ? 'File drift' : 'Server health'} · ${serverTitle(incident.server)}`
|
? `${incident.kind === 'drift' ? 'File drift' : 'Server health'} · ${serverTitle(incident.server)}`
|
||||||
: 'Maintenance finding'}
|
: 'Maintenance finding'}
|
||||||
subtitle={incident?.id}
|
|
||||||
>
|
>
|
||||||
{#snippet crumbs()}<Breadcrumbs
|
{#snippet crumbs()}<Breadcrumbs
|
||||||
items={[{ label: 'Maintenance', href: '/maintenance' }, { label: 'Incident' }]}
|
items={[{ label: 'Maintenance', href: '/maintenance' }, { label: 'Incident' }]}
|
||||||
@@ -253,6 +253,15 @@
|
|||||||
)}>Open server logs</a
|
)}>Open server logs</a
|
||||||
>
|
>
|
||||||
</div>{/if}
|
</div>{/if}
|
||||||
|
<details>
|
||||||
|
<summary>Technical details</summary>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>Incident ID</dt>
|
||||||
|
<dd><CopyValue value={incident.id} label="incident ID" /></dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-labelledby="recovery-title">
|
<section aria-labelledby="recovery-title">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import type { AuthUser, UserRole } from '$lib/auth.js';
|
import type { AuthUser, UserRole } from '$lib/auth.js';
|
||||||
import { confirmAction } from '$ui/confirm.js';
|
import { confirmAction } from '$ui/confirm.js';
|
||||||
|
import { auditPresentation } from '$ui/audit.js';
|
||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
@@ -101,24 +102,6 @@
|
|||||||
auditLoading = false;
|
auditLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const actionLabels: Record<string, string> = {
|
|
||||||
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(() => {
|
onMount(() => {
|
||||||
if (canViewUsers) void Promise.all([load(), loadAudit()]);
|
if (canViewUsers) void Promise.all([load(), loadAudit()]);
|
||||||
else {
|
else {
|
||||||
@@ -339,7 +322,7 @@
|
|||||||
<div class="audit-heading">
|
<div class="audit-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2 id="audit-title">Account activity</h2>
|
<h2 id="audit-title">Account activity</h2>
|
||||||
<p class="muted">The latest 200 sign-in and account events.</p>
|
<p class="muted">The latest 200 sign-in, account, and action events.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="ghost" disabled={auditLoading} onclick={loadAudit}
|
<Button size="sm" variant="ghost" disabled={auditLoading} onclick={loadAudit}
|
||||||
>Refresh activity</Button
|
>Refresh activity</Button
|
||||||
@@ -354,27 +337,27 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead
|
<thead
|
||||||
><tr
|
><tr
|
||||||
><th>Time</th><th>Event</th><th>By</th><th>Account</th><th
|
><th>Time</th><th>Event</th><th>By</th><th>Account or action</th><th
|
||||||
>Details</th
|
>Details</th
|
||||||
></tr
|
></tr
|
||||||
></thead
|
></thead
|
||||||
><tbody>
|
><tbody>
|
||||||
{#each events as event (event.id)}{@const details = auditDetails(
|
{#each events as event (event.id)}{@const presentation =
|
||||||
event.details
|
auditPresentation(event)}<tr
|
||||||
)}<tr
|
|
||||||
><td class="event-time"
|
><td class="event-time"
|
||||||
><RelativeTime value={event.createdAt} /></td
|
><RelativeTime value={event.createdAt} /></td
|
||||||
><td>{actionLabels[event.action] ?? event.action}</td><td
|
><td>{presentation.label}</td><td
|
||||||
>{event.actor ? `@${event.actor}` : 'Unauthenticated'}</td
|
>{event.actor ? `@${event.actor}` : 'Unauthenticated'}</td
|
||||||
><td
|
><td
|
||||||
>{event.targetId
|
>{presentation.requestAction ??
|
||||||
? (users.find((user) => user.id === event.targetId)
|
(event.targetId
|
||||||
?.username ?? event.targetId)
|
? (users.find((user) => user.id === event.targetId)
|
||||||
: '—'}</td
|
?.username ?? event.targetId)
|
||||||
|
: '—')}</td
|
||||||
><td
|
><td
|
||||||
>{#if details}<details>
|
>{#if presentation.details}<details>
|
||||||
<summary>View details</summary>
|
<summary>Technical details</summary>
|
||||||
<pre>{details}</pre>
|
<pre>{presentation.details}</pre>
|
||||||
</details>{:else}—{/if}</td
|
</details>{:else}—{/if}</td
|
||||||
></tr
|
></tr
|
||||||
>{/each}
|
>{/each}
|
||||||
|
|||||||
@@ -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<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
vi.mock('../../src/lib/ui/poll.js', () => ({
|
||||||
|
pollVisible: (task: () => Promise<void>) => {
|
||||||
|
void task();
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let component: ReturnType<typeof mount> | undefined;
|
||||||
|
afterEach(async () => {
|
||||||
|
if (component) await unmount(component);
|
||||||
|
component = undefined;
|
||||||
|
document.body.replaceChildren();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function overview(release: Partial<PlutoniumRelease> = {}): 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<HTMLElement>('#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<Response>((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();
|
||||||
|
});
|
||||||
@@ -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<typeof page.subscribe>[0]) => page.subscribe(run) }
|
||||||
|
}));
|
||||||
|
vi.mock('$app/navigation', () => ({ invalidateAll: vi.fn() }));
|
||||||
|
|
||||||
|
let component: ReturnType<typeof mount> | 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<HTMLTableRowElement>('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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user