Clarify server actions and group maintenance failures

This commit is contained in:
2026-10-07 19:21:53 -04:00
parent bcecc42dd1
commit c39563daef
15 changed files with 559 additions and 111 deletions
+69 -15
View File
@@ -15,9 +15,13 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import StatusPip from '$ui/components/primitives/StatusPip.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import { groupMaintenanceScanErrors, groupMaintenanceVersions } from '$ui/maintenance-presentation.js';
let data = $state<MaintenanceOverview | null>(null);
let error = $state('');
@@ -34,6 +38,7 @@
let scheduleLoaded = false;
let displayedZone = $state('');
let request = 0;
let checkedAt = $state<number | null>(null);
const tabs = [{ id: 'overview', label: 'Overview' }, { id: 'policies', label: 'Policies & schedules' }, { id: 'activity', label: 'Activity' }];
const policies: { id: MaintenancePolicy; title: string; description: string }[] = [
{ id: 'health', title: 'Health recovery', description: 'Restart unhealthy servers, then rebuild once if recovery fails. Rebuilding deletes instance files and installs the latest promoted setup.' },
@@ -44,6 +49,9 @@
const outdated = $derived(versions.filter(v => v.update_status === 'outdated'));
const unknown = $derived(versions.filter(v => v.update_status === 'unknown'));
const activeJobs = $derived(data?.runtime.jobs.filter(j => !j.server_deleted_at && ['queued', 'restarting'].includes(j.state)) ?? []);
const scanErrors = $derived(groupMaintenanceScanErrors(data?.recovery.scanError, data?.runtime.servers.map(version => version.server) ?? []));
const versionGroups = $derived(groupMaintenanceVersions(versions, activeJobs));
const updatableServers = $derived(versionGroups.flatMap(group => group.updatable));
const activities = $derived([
...(data?.recovery.incidents ?? []).map(i => ({
key: i.id, kind: i.kind, server: i.server, server_deleted_at: i.server_deleted_at, at: i.first_seen_at,
@@ -92,6 +100,7 @@
}
function receive(body: MaintenanceOverview) {
data = body;
checkedAt = Date.now();
if (!scheduleLoaded) { scheduleEnabled = body.runtime.schedule.enabled; scheduleLoaded = true; }
}
async function load() {
@@ -129,8 +138,9 @@
{/snippet}
</SectionHeader>
{#if error}<p class="ui-error" role="alert">{error} {data ? 'Showing the last successful refresh.' : ''} <Button size="sm" onclick={load} disabled={busy}>Retry refresh</Button></p>{/if}
{#if !error}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 5 seconds" />{/if}
{#if notice}<p role="status">{notice}</p>{/if}
{#if data?.policies.paused}<div class="paused"><strong>All maintenance is paused.</strong> Checks and verification continue. New actions, scheduled runs, and queued maintenance are held. Submitted operations finish recovery. Campaigns, betas, and manual Provisioning actions continue.</div>{/if}
{#if data?.policies.paused}<div class="paused"><strong>All maintenance is paused.</strong> Checks and verification continue. New actions, scheduled runs, and queued maintenance are held. Submitted operations finish recovery. Campaigns, betas, and manual server operations continue.</div>{/if}
{#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/provisioning" />{/if}
<Tabs {tabs} bind:value={view} onchange={() => saveContext()} ariaLabel="Maintenance views" idPrefix="maintenance-tabs" panelId="maintenance-panel" />
<div id="maintenance-panel" role="tabpanel" aria-labelledby={`maintenance-tabs-${view}`} class="panels">
@@ -159,10 +169,21 @@
</section>
{:else}
{#if view === 'overview'}
<div class="summary"><span><strong>{attention}</strong> need attention</span><span><strong>{waiting}</strong> active</span><span><strong>{outdated.length}</strong> outdated</span><span><strong>{unknown.length}</strong> versions unverified</span></div>
<div class="summary"><span><strong>{attention}</strong> {attention === 1 ? 'needs' : 'need'} attention</span><span><strong>{waiting}</strong> active</span><span><strong>{outdated.length}</strong> outdated</span><span><strong>{unknown.length}</strong> {unknown.length === 1 ? 'version' : 'versions'} unverified</span></div>
{#if data.runtime.schedule.nextAt}<p class="muted">Next scheduled restart: {$timeFormat.dateTime(data.runtime.schedule.nextAt)} · <a href="/maintenance?view=policies#schedule">Manage schedule</a></p>{/if}
{#if data.recovery.lastScanAt}<p class="muted">Health and drift checked {$timeFormat.dateTime(data.recovery.lastScanAt)}</p>{/if}
{#if data.recovery.scanError}<p class="ui-error" role="alert">{data.recovery.scanError}</p>{/if}
{#if scanErrors.length}
<section class="scan-errors" aria-labelledby="scan-errors-heading">
<div class="row-heading"><h2 id="scan-errors-heading">Checks need attention</h2><Button size="sm" disabled={busy} onclick={() => act({ action: 'scan' })}>Retry checks</Button></div>
<ul class="error-groups">{#each scanErrors as group (group.key)}
<li><h3><StatusPip tone="warning" />{group.title}{group.servers.length ? ` · ${group.servers.length} ${group.servers.length === 1 ? 'server' : 'servers'}` : ''}</h3>
<p>{group.description}</p>
{#if group.servers.length}<ul class="affected-servers">{#each group.servers as name}<li><a href={contextHref(`/servers/${encodeURIComponent(name)}?tab=diagnostics`, $page.url)}>{serverTitle(name)}</a></li>{/each}</ul>{/if}
<details><summary>Technical details</summary><ul class="technical-details">{#each group.details as detail}<li>{#if detail.server}<strong>{serverTitle(detail.server)}</strong>{/if}<pre>{detail.message}</pre></li>{/each}</ul></details>
</li>
{/each}</ul>
</section>
{/if}
{/if}
<div class="filters">
<label>Capability<select bind:value={kind} onchange={() => saveContext()}><option value="all">All capabilities</option><option value="health">Health recovery</option><option value="drift">Configuration drift</option><option value="updates">Plutonium updates</option><option value="schedule">Scheduled restarts</option></select></label>
@@ -171,19 +192,34 @@
</div>
{#if view === 'overview' && ['all', 'updates'].includes(kind)}
<section class="card" id="updates">
<div class="row-heading"><h2>Plutonium updates</h2><Button disabled={busy || data.policies.paused || !outdated.some(v => !activeJobs.some(j => j.server === v.server))} onclick={() => act({ action: 'update', servers: outdated.map(v => v.server) })}>Update outdated servers on restart</Button></div>
<div class="row-heading"><h2>Plutonium updates</h2>{#if updatableServers.length}<Button disabled={busy || data.policies.paused} onclick={() => act({ action: 'update', servers: updatableServers })}>Update outdated servers on restart</Button>{/if}</div>
<p>Published revision: <strong>{data.runtime.release.revision ? `r${data.runtime.release.revision}` : 'Unknown'}</strong> · <a href={PLUTONIUM_MANIFEST_URL} target="_blank" rel="noreferrer">Production manifest</a> · <a href="https://plutonium.pw/docs/changelog/" target="_blank" rel="noreferrer">Changelog</a></p>
{#if data.runtime.release.checkedAt}<p class="muted">Checked {$timeFormat.dateTime(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.</p>{/if}
<p class="muted">Plutainer applies the available release at startup when auto-update is enabled. Maintenance verifies the running revision after recovery. Unknown versions require a successful check before automatic updates.</p>
<div class="table-wrap"><table><thead><tr><th>Server</th><th>Running revision</th><th>Status</th><th>Action</th></tr></thead><tbody>
{#each versions as version (version.server)}
{@const pending = activeJobs.find(j => j.server === version.server)}
<tr><td><a href={`/servers/${encodeURIComponent(version.server)}`}><ServerIdentity name={version.server} /></a></td><td>{version.revision ? `r${version.revision}` : 'Unknown'}{#if version.checked_at}<small>{$timeFormat.dateTime(version.checked_at)}</small>{/if}{#if version.error}<small>{version.error}</small>{/if}</td>
<td>{version.update_status === 'outdated' ? 'Update available' : version.update_status === 'current' ? 'Up to date' : version.update_status === 'ahead' ? 'Ahead of published release' : version.update_status === 'stopped' ? 'Intentionally stopped' : 'Unverified'}</td>
<td>{#if pending}<a href={pending.provisioning_id ? `/provisioning/${encodeURIComponent(pending.provisioning_id)}` : '#activity'}>Restart pending</a>{:else if version.update_status === 'outdated'}<Button size="sm" disabled={busy || data.policies.paused} onclick={() => act({ action: 'update', servers: [version.server] })}>Update on restart</Button>{/if}</td></tr>
{:else}<tr><td colspan="4">No matching fleet servers.</td></tr>{/each}
</tbody></table></div>
<div class="version-groups">{#each versionGroups as group (group.key)}
<article class="version-group">
<div class="row-heading">
<div class="version-summary"><strong>{group.versions.length} {group.versions.length === 1 ? 'server' : 'servers'} · {group.revision ? `reported r${group.revision}` : 'revision unknown'}</strong><Badge variant={group.tone}><StatusPip tone={group.tone} size="sm" />{group.label}</Badge></div>
<div class="version-actions">
{#if group.pending.length}<span>{group.pending.length} {group.pending.length === 1 ? 'restart' : 'restarts'} pending</span>{/if}
{#if group.status === 'unknown'}<Button size="sm" disabled={busy} onclick={() => act({ action: 'scan' })}>Retry version check</Button>
{:else if group.updatable.length}<Button size="sm" disabled={busy || data.policies.paused} onclick={() => act({ action: 'update', servers: group.updatable })}>Update {group.updatable.length === 1 ? 'server' : `${group.updatable.length} servers`} on restart</Button>{/if}
</div>
</div>
<details><summary>{group.versions.length === 1 ? 'Server and check details' : `Show ${group.versions.length} servers and check details`}</summary>
<ul class="version-members">{#each group.versions as version (version.server)}
{@const pending = group.pending.find(job => job.server === version.server)}
<li><div class="row-heading"><a href={contextHref(`/servers/${encodeURIComponent(version.server)}`, $page.url)}><ServerIdentity name={version.server} /></a>
{#if pending}<span class="pending-job"><StatusBadge state={pending.state} />{#if pending.provisioning_id}<a href={contextHref(`/provisioning/${encodeURIComponent(pending.provisioning_id)}`, $page.url)}>View restart</a>{:else}<a href="#activity">View pending restart</a>{/if}</span>{/if}
</div>
{#if version.checked_at}<small>Last checked <time datetime={version.checked_at}>{$timeFormat.dateTime(version.checked_at)}</time></small>{/if}
{#if version.error}<pre class="version-error">{version.error}</pre>{/if}
</li>
{/each}</ul>
</details>
</article>
{:else}<EmptyState title="No matching servers" description="Choose another server name to see its reported Plutonium revision." />{/each}</div>
</section>
{/if}
<section id="activity">
@@ -215,11 +251,29 @@
.summary, .paused { padding: 20px; background: var(--surface-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); }
.paused { line-height: 1.7; }
h2 { font-size: var(--text-md); } h3 { font-size: var(--text-sm); } h2, h3, p { margin: 0; }
p, label, td, th { font-size: var(--text-sm); line-height: 1.6; }
p, label { font-size: var(--text-sm); line-height: 1.6; }
.muted, small { color: var(--text-secondary); } small { display: block; }
a { color: var(--accent-primary-hover); } label { display: grid; gap: 6px; } .toggle { display: flex; align-items: center; }
input:not([type='checkbox']), select { color: inherit; background: var(--surface); padding: 9px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); max-width: 100%; }
.table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; text-align: left; } td, th { padding: 12px 8px; border-bottom: 1px solid var(--border-subtle); }
input:not([type='checkbox']), select { color: inherit; background: var(--surface-sunken); padding: 9px; border: 1px solid var(--border-control); border-radius: var(--radius-sm); max-width: 100%; }
.scan-errors { padding: var(--space-4); border: 1px solid color-mix(in srgb, var(--state-warning) 35%, var(--border-subtle)); border-radius: var(--radius-md); background: var(--surface); }
.error-groups, .technical-details, .version-members { list-style: none; padding: 0; margin: 0; }
.error-groups { display: grid; gap: var(--space-4); }
.error-groups > li + li { border-top: 1px solid var(--border-subtle); padding-top: var(--space-4); }
.error-groups h3 { display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-2); }
.error-groups p { color: var(--text-secondary); }
.affected-servers { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); padding: 0; margin: var(--space-3) 0; list-style: none; font-size: var(--text-sm); }
details { margin-top: var(--space-2); min-width: 0; }
summary { cursor: pointer; color: var(--text-secondary); font-size: var(--text-xs); }
.technical-details > li { margin-top: var(--space-3); font-size: var(--text-xs); }
pre { max-width: 100%; white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--font-mono); font-size: var(--text-xs); line-height: var(--leading-mono); color: var(--text-secondary); margin: var(--space-2) 0 0; }
.version-groups { display: grid; gap: var(--space-2); }
.version-group { min-width: 0; padding: var(--space-3) var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.version-summary, .version-actions, .pending-job { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); font-size: var(--text-sm); }
.version-summary strong { font-weight: var(--weight-medium); font-variant-numeric: tabular-nums; }
.version-actions { font-size: var(--text-xs); color: var(--text-secondary); }
.version-members > li { padding: var(--space-3) 0; }
.version-members > li + li { border-top: 1px solid var(--border-subtle); }
.version-members small { margin-top: var(--space-1); font-size: var(--text-xs); }
.activity article, section { scroll-margin-top: 100px; }
@media (max-width: 640px) { .card { padding: 18px; } }
</style>