Show provisioning requesters and reserve Maxis for system actions
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user