Show provisioning requesters and reserve Maxis for system actions

This commit is contained in:
2026-10-08 16:19:49 -04:00
parent 2b155ded8f
commit d8fb7e1d66
10 changed files with 77 additions and 15 deletions
+3
View File
@@ -1,5 +1,8 @@
export type UserRole = 'admin' | 'operator' | 'viewer';
/** Reserved identity for work requested by the controller. */
export const systemUsername = 'Maxis';
export interface AuthUser {
id: string;
username: string;
+1 -1
View File
@@ -29,7 +29,7 @@ export function provisioningMatchesSource(row: Pick<ProvisioningListRow, 'source
export type ProvisioningListRow = Pick<ProvisioningDeployment,
'id' | 'server' | 'server_deleted_at' | 'action' | 'state' | 'stage' | 'started_at' |
'finished_at' | 'triggered_by' | 'error' | 'warnings' | 'server_exists'> & {
'finished_at' | 'triggered_by' | 'requested_by' | 'error' | 'warnings' | 'server_exists'> & {
canCancelRemoval: number; canCancel?: number; sources?: ProvisioningSource[];
phase?: string | null; phaseStartedAt?: string | null; waitCode?: string | null;
};
+4 -2
View File
@@ -2,7 +2,7 @@ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypt
import { db } from '../db/index.js';
import { ensureAuthSchema } from './schema.js';
import { hashPassword, verifyPassword } from './passwords.js';
import type { AuthUser, UserRole } from '../../auth.js';
import { systemUsername, type AuthUser, type UserRole } from '../../auth.js';
export const SESSION_COOKIE = '__Host-ec_session';
export const SESSION_LIFETIME_MS = 12 * 60 * 60_000;
@@ -25,7 +25,9 @@ export function listUsers(): AuthUser[] { return (database().prepare('SELECT * F
export function setupRequired(): boolean { return !(database().prepare('SELECT 1 FROM auth_users LIMIT 1').get()); }
function username(value: unknown): string {
if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{2,63}$/.test(value)) throw new AuthError('Usernames need 3–64 letters, numbers, dots, underscores or hyphens.');
return value.toLowerCase();
const normalized = value.toLowerCase();
if (normalized === systemUsername.toLowerCase()) throw new AuthError(`${systemUsername} is reserved for system actions. Choose another username.`);
return normalized;
}
function displayName(value: unknown, fallback: string): string {
const name = value === undefined ? fallback : value;
+1 -1
View File
@@ -303,7 +303,7 @@ const reviewDeployment = `d.state IN ('failed', 'interrupted', 'warning') AND NO
SELECT 1 FROM retention_controls c WHERE c.category='provisioning' AND c.record_id=d.id
AND c.resolved_at >= COALESCE(d.finished_at,d.started_at))`;
const deploymentListColumns = `d.id, d.server, d.server_deleted_at, d.action, d.state, d.stage,
d.started_at, d.finished_at, d.triggered_by, d.error, d.warnings,
d.started_at, d.finished_at, d.triggered_by, d.requested_by, d.error, d.warnings,
d.phase_key AS phase, d.phase_started_at AS phaseStartedAt,
CASE WHEN json_valid(d.observation_json) THEN json_extract(d.observation_json,'$.blocker.code') END AS waitCode,
(d.server_deleted_at IS NULL AND EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server AND s.destroyed_at IS NULL)) AS server_exists,
+6 -1
View File
@@ -1,5 +1,10 @@
import { provisioningPhases, type ProvisioningDetail, type ProvisioningEvent, type ProvisioningEventPage, type ProvisioningPhase } from '$lib/provisioning-detail.js';
import { provisioningPhases, type ProvisioningDeployment, type ProvisioningDetail, type ProvisioningEvent, type ProvisioningEventPage, type ProvisioningPhase } from '$lib/provisioning-detail.js';
import { actionCompleted, actionLabels, provisioningActions, type ProvisioningAction } from '$lib/provisioning.js';
import { systemUsername } from '$lib/auth.js';
export function provisioningRequester(operation: Pick<ProvisioningDeployment, 'requested_by' | 'triggered_by'>): string {
return operation.requested_by ?? (operation.triggered_by === 'ui' ? 'Not recorded' : systemUsername);
}
export const activeProvisioningStates = ['queued', 'waiting', 'running', 'awaiting-health'];
export type ProgressState = 'done' | 'current' | 'waiting' | 'failed' | 'pending' | 'cancelled' | 'unknown';
+6 -4
View File
@@ -22,7 +22,7 @@
import { contextHref } from '$lib/navigation-context.js';
import { confirmAction } from '$ui/confirm.js';
import { serverTitle } from '$ui/server-labels.js';
import { elapsedTime } from '$ui/provisioning-presentation.js';
import { elapsedTime, provisioningRequester } from '$ui/provisioning-presentation.js';
import { provisioningQueueStatus, provisioningWarnings } from '$ui/provisioning-queue.js';
let lifecycleOpen = $state(false);
@@ -210,7 +210,7 @@
{#snippet operationTable(rows: Deployment[])}
<div class="table-scroll"><table>
<thead><tr><th scope="col">Server</th><th scope="col">Operation</th><th scope="col">Status</th><th scope="col">Source</th><th scope="col">Started</th><th scope="col" class="align-right">Details</th></tr></thead>
<thead><tr><th scope="col">Server</th><th scope="col">Operation</th><th scope="col">Status</th><th scope="col">Source</th><th scope="col">Requester</th><th scope="col">Started</th><th scope="col" class="align-right">Details</th></tr></thead>
<tbody>{#each rows as row (row.id)}
{@const status = provisioningQueueStatus(row)}
{@const primarySource = row.sources?.[0]}
@@ -221,14 +221,15 @@
<td><a href={operationHref}>{actionLabels[row.action]}</a></td>
<td><Badge variant={status.tone} size="md">{status.label}</Badge>{#if ['queued','waiting'].includes(status.group) && row.phaseStartedAt && now - Date.parse(row.phaseStartedAt) >= 600000}<small>In this stage {elapsedTime(row.phaseStartedAt, now)}</small>{/if}</td>
<td class="source-cell">{#if primarySource?.href}<a href={contextHref(primarySource.href, $page.url)}>{provisioningSourceLabel(row)}</a>{:else}<span>{provisioningSourceLabel(row)}</span>{/if}{#if (row.sources?.length ?? 0) > 1}<small>+{row.sources!.length - 1} more</small>{/if}</td>
<td class="requester-cell">{provisioningRequester(row)}</td>
<td><RelativeTime value={row.started_at} /></td>
<td class="align-right"><button class="text-button" aria-expanded={isExpanded} aria-controls={`operation-${row.id}`} aria-label={`${isExpanded ? 'Hide' : 'Show'} ${actionLabels[row.action].toLowerCase()} details for ${serverTitle(row.server)}`} onclick={() => expanded = { ...expanded, [row.id]: !isExpanded }}>{isExpanded ? 'Hide' : 'Details'} <span aria-hidden="true">{isExpanded ? '−' : '+'}</span></button></td>
</tr>
<tr hidden={!isExpanded} id={`operation-${row.id}`} class="detail-row"><td colspan="6">
<tr hidden={!isExpanded} id={`operation-${row.id}`} class="detail-row"><td colspan="7">
<div class="operation-details">
<p>{row.stage}</p>{#if row.error}<p class="error">{row.error}</p>{/if}
{#each provisioningWarnings(row.warnings) as warning}<p class="warning">{warning}</p>{/each}
<dl><div><dt>Started</dt><dd>{fmt(row.started_at)}</dd></div>{#if row.finished_at}<div><dt>Finished</dt><dd>{fmt(row.finished_at)}</dd></div>{/if}<div><dt>{row.finished_at ? 'Duration' : 'Elapsed'}</dt><dd>{elapsedTime(row.started_at, row.finished_at ? Date.parse(row.finished_at) : now)}</dd></div><div><dt>Operation ID</dt><dd class="mono">{row.id}</dd></div></dl>
<dl><div><dt>Requester</dt><dd>{provisioningRequester(row)}</dd></div><div><dt>Started</dt><dd>{fmt(row.started_at)}</dd></div>{#if row.finished_at}<div><dt>Finished</dt><dd>{fmt(row.finished_at)}</dd></div>{/if}<div><dt>{row.finished_at ? 'Duration' : 'Elapsed'}</dt><dd>{elapsedTime(row.started_at, row.finished_at ? Date.parse(row.finished_at) : now)}</dd></div><div><dt>Operation ID</dt><dd class="mono">{row.id}</dd></div></dl>
{#if row.sources?.length}<div class="detail-links">{#each row.sources as item}{#if item.href}<a href={contextHref(item.href, $page.url)}>{item.label}</a>{:else}<span class="muted">{item.label}</span>{/if}{/each}</div>{/if}
<div class="detail-links"><a href={operationHref}>Open operation</a>{#if row.server_exists && !row.server_deleted_at}<a href={contextHref(`/servers/${encodeURIComponent(row.server)}?tab=diagnostics`, $page.url)}>Diagnostics</a><a href={contextHref(`/servers/${encodeURIComponent(row.server)}?tab=logs`, $page.url)}>Open logs</a>{/if}{#if row.canCancel && !row.server_deleted_at}<Button variant="danger" size="sm" disabled={cancelling} onclick={() => cancelRequests([row])}>Cancel operation</Button>{/if}</div>
</div>
@@ -249,6 +250,7 @@
th { padding:10px 12px; color:var(--text-tertiary); font-size:var(--text-xs); font-weight:var(--weight-medium); border-bottom:1px solid var(--border-strong); }
td { padding:16px 12px; border-bottom:1px solid var(--border-subtle); vertical-align:middle; }tbody tr:hover { background:var(--surface); }
a { color:var(--accent-primary-hover); }.server-cell { min-width:160px; font-weight:var(--weight-semibold); }.source-cell { max-width:240px; overflow-wrap:anywhere; }
.requester-cell { max-width:180px; overflow-wrap:anywhere; }
small { display:block; margin-top:6px; color:var(--text-tertiary); font-size:var(--text-xs); }.align-right { text-align:right; }
.text-button { border:0; background:transparent; color:var(--text-secondary); font:inherit; padding:6px 0; cursor:pointer; white-space:nowrap; }
.detail-row { background:var(--surface); }.operation-details { display:grid; gap:16px; line-height:1.6; overflow-wrap:anywhere; }
+2 -2
View File
@@ -9,7 +9,7 @@
import { serverTitle } from '$ui/server-labels.js';
import { createProvisioningRefresh, type ProvisioningRefreshState } from '$ui/provisioning-refresh.js';
import { confirmAction } from '$ui/confirm.js';
import { elapsedTime, isProvisioningDetail, provisioningOutcome, provisioningReturn } from '$ui/provisioning-presentation.js';
import { elapsedTime, isProvisioningDetail, provisioningOutcome, provisioningRequester, provisioningReturn } from '$ui/provisioning-presentation.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
@@ -171,7 +171,7 @@
{/if}
<Card as="section" padding="md" ariaLabel="Operation details">
<div class="context-section"><h2>Operation details</h2><dl class="metadata">
<div><dt>Requested by</dt><dd>{deployment.requested_by ?? deployment.triggered_by}</dd></div>
<div><dt>Requester</dt><dd>{provisioningRequester(deployment)}</dd></div>
<div><dt>Requested at</dt><dd><RelativeTime value={deployment.started_at} /></dd></div>
<div><dt>Submission attempted</dt><dd><RelativeTime value={deployment.submitted_at} missing="Not recorded" /></dd></div>
<div><dt>Finished</dt><dd><RelativeTime value={deployment.finished_at} missing={outcome.active ? 'In progress' : 'Not recorded'} /></dd></div>