Use friendly UI labels and release feedback (Refs #120)
CI / checks (push) Successful in 6m39s

This commit is contained in:
2026-10-17 09:54:01 -04:00
parent d93a766578
commit 2e6eb0bdb0
8 changed files with 387 additions and 51 deletions
+100
View File
@@ -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
View File
@@ -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}`;
+11 -9
View File
@@ -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<string[]>([]);
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 @@
}
</script>
<svelte:head
><title
>{detail ? `${detail.deployment.action} campaign · ${detail.deployment.id}` : 'Campaign'} | Electric
Cherry</title
></svelte:head
>
<svelte:head><title>{title} | Electric Cherry</title></svelte:head>
{#if loadError}<p role="alert" class="err">
{loadError} <button onclick={loadDetail}>Retry</button>
</p>{/if}
@@ -288,10 +290,10 @@
{#if detail}
{@const action = detail.deployment.action}
<div class="page">
<SectionHeader title={`${action.charAt(0).toUpperCase() + action.slice(1)} campaign`}>
<SectionHeader {title}>
{#snippet crumbs()}
<Breadcrumbs
items={[{ label: 'Campaigns', href: '/campaigns' }, { label: action }]}
items={[{ label: 'Campaigns', href: '/campaigns' }, { label: title }]}
/>
{/snippet}
{#snippet actions()}
+26 -6
View File
@@ -542,10 +542,15 @@
>{/if}
</div>
<p>
Published revision: <strong
{data.runtime.release.error && data.runtime.release.revision !== null
? 'Last known published revision'
: 'Published revision'}:
<strong
>{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"
@@ -559,11 +564,26 @@
>
</p>
{#if data.runtime.release.checkedAt}<p class="muted">
Checked <RelativeTime value={data.runtime.release.checkedAt} />
</p>{/if}
{#if data.runtime.release.error}<p class="ui-error" role="alert">
{data.runtime.release.error} Displayed release information is cached.
{data.runtime.release.error ? 'Last attempted' : 'Checked'}
<RelativeTime value={data.runtime.release.checkedAt} />
</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">
Plutainer applies the available release at startup when auto-update is
enabled. Maintenance verifies the running revision after recovery.
+10 -1
View File
@@ -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<WatchdogDetail | null>(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()}<Breadcrumbs
items={[{ label: 'Maintenance', href: '/maintenance' }, { label: 'Incident' }]}
@@ -253,6 +253,15 @@
)}>Open server logs</a
>
</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 aria-labelledby="recovery-title">
+14 -31
View File
@@ -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<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(() => {
if (canViewUsers) void Promise.all([load(), loadAudit()]);
else {
@@ -339,7 +322,7 @@
<div class="audit-heading">
<div>
<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>
<Button size="sm" variant="ghost" disabled={auditLoading} onclick={loadAudit}
>Refresh activity</Button
@@ -354,27 +337,27 @@
<table>
<thead
><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
></tr
></thead
><tbody>
{#each events as event (event.id)}{@const details = auditDetails(
event.details
)}<tr
{#each events as event (event.id)}{@const presentation =
auditPresentation(event)}<tr
><td class="event-time"
><RelativeTime value={event.createdAt} /></td
><td>{actionLabels[event.action] ?? event.action}</td><td
><td>{presentation.label}</td><td
>{event.actor ? `@${event.actor}` : 'Unauthenticated'}</td
><td
>{event.targetId
? (users.find((user) => user.id === event.targetId)
?.username ?? event.targetId)
: '—'}</td
>{presentation.requestAction ??
(event.targetId
? (users.find((user) => user.id === event.targetId)
?.username ?? event.targetId)
: '—')}</td
><td
>{#if details}<details>
<summary>View details</summary>
<pre>{details}</pre>
>{#if presentation.details}<details>
<summary>Technical details</summary>
<pre>{presentation.details}</pre>
</details>{:else}—{/if}</td
></tr
>{/each}