Unify retained operation history in Activity
This commit is contained in:
@@ -53,3 +53,13 @@ Keyboard shortcut help is available from the account menu and with `?`.
|
||||
Detail pages use `Breadcrumbs`. Its parent link restores the validated `returnTo`
|
||||
URL, including filters, sorting, pagination, and anchors. Supply `fallback` when
|
||||
the default parent also needs filters. Do not add a duplicate Back link.
|
||||
|
||||
### Activity history
|
||||
|
||||
Use `ActivityLog` for the fleet timeline and scoped history. The server History
|
||||
tab fixes the server filter; Maintenance fixes the maintenance/recovery source.
|
||||
The API counts and pages retained records together, with stable ordering. Parent
|
||||
operations represent their host receipts and maintenance verification so a single
|
||||
user action does not appear multiple times. Routine successful host checks stay
|
||||
in the technical Host operation history. Keep detail links contextual so their
|
||||
parent breadcrumb restores filters and pagination.
|
||||
|
||||
@@ -17,9 +17,11 @@ Campaigns retain managed-file deployment evidence; Collections define lineups.
|
||||
when the running revision is unverified.
|
||||
- **Policies & schedules** controls health recovery, configuration drift, automatic
|
||||
Plutonium updates, and the optional daily restart schedule independently.
|
||||
- **Activity** combines recovery findings, update results, and scheduled restarts.
|
||||
Follow a finding for evidence and attempts, or its Provisioning operation for
|
||||
execution progress. Cancel unsubmitted runtime restarts here.
|
||||
- **Activity** uses the shared Activity log, scoped to maintenance and recovery.
|
||||
Filter retained findings, update results, and scheduled restarts in one timeline.
|
||||
Follow a finding for evidence or an operation for execution progress. Use
|
||||
Overview’s **Active work & attention** to cancel waiting work or retry failed
|
||||
verification after inspection.
|
||||
|
||||
Fleet and server details link their revision indicators to Maintenance. Failed
|
||||
runtime maintenance appears in Fleet attention, even when the underlying restart
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Provisioning
|
||||
|
||||
Open **Activity → Operations** for instance lifecycle work and persistent
|
||||
activity. **Fleet → Collections** owns reusable lineups and collection launch
|
||||
Open **Activity → Activity log** for retained lifecycle, host, campaign, and
|
||||
maintenance history. Use **Manage servers** from that page or command search
|
||||
for server queues, bulk cancellation, and lifecycle actions. **Fleet → Collections** owns reusable lineups and collection launch
|
||||
progress; their individual operations remain visible in Provisioning.
|
||||
Provisioning owns the queues, safety checks, execution, and
|
||||
recovery for all six actions. Create a server using **Create server**. For an existing
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
export const activityTypes = {
|
||||
create: 'Create server', start: 'Start server', restart: 'Restart server', rebuild: 'Rebuild server',
|
||||
stop: 'Stop server', remove: 'Remove server', update: 'Game update', files: 'File changes',
|
||||
health: 'Health finding', drift: 'File drift', host: 'Host operation', beta: 'Beta test'
|
||||
} as const;
|
||||
export type ActivityType = keyof typeof activityTypes;
|
||||
export const activitySources = {
|
||||
provisioning: 'Server operations', maintenance: 'Maintenance & recovery', recovery: 'Recovery',
|
||||
campaign: 'Campaigns', beta: 'Betas', host: 'Hosts', autoscaling: 'Autoscaling'
|
||||
} as const;
|
||||
export type ActivitySource = keyof typeof activitySources;
|
||||
export const activityStates = {
|
||||
queued: 'Queued', running: 'In progress', succeeded: 'Completed', warning: 'Completed with warnings',
|
||||
failed: 'Failed', cancelled: 'Cancelled', attention: 'Needs attention', unknown: 'Unknown'
|
||||
} as const;
|
||||
export type ActivityState = keyof typeof activityStates;
|
||||
|
||||
export interface ActivityRow {
|
||||
id: string;
|
||||
at: string;
|
||||
type: ActivityType;
|
||||
source: ActivitySource;
|
||||
/** A shared operation can have more than one caller; filters match any membership. */
|
||||
sources: ActivitySource[];
|
||||
state: ActivityState;
|
||||
rawState: string;
|
||||
title: string;
|
||||
detail: string | null;
|
||||
server: string | null;
|
||||
serverDeletedAt: string | null;
|
||||
hostId: string | null;
|
||||
hostName: string | null;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface ActivityQuery {
|
||||
server?: string;
|
||||
host?: string;
|
||||
type?: ActivityType;
|
||||
source?: ActivitySource;
|
||||
state?: ActivityState;
|
||||
days?: number;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
export interface ActivityPage {
|
||||
rows: ActivityRow[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
query: ActivityQuery;
|
||||
}
|
||||
|
||||
function selection<T extends string>(value: string | null, labels: Record<T, string>): T | undefined {
|
||||
return value !== null && Object.hasOwn(labels, value) ? value as T : undefined;
|
||||
}
|
||||
function integer(value: string | null, fallback: number, min: number, max: number): number {
|
||||
if (value === null || !/^\d+$/.test(value)) return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
|
||||
}
|
||||
|
||||
/** Server and host are exact identities; q searches friendly names and safe summaries. */
|
||||
export function parseActivityQuery(params: URLSearchParams): ActivityQuery {
|
||||
const query: ActivityQuery = {
|
||||
limit: integer(params.get('limit'), 25, 1, 100),
|
||||
offset: integer(params.get('offset'), 0, 0, 1_000_000_000)
|
||||
};
|
||||
for (const key of ['server', 'host', 'q'] as const) {
|
||||
const value = params.get(key)?.trim().slice(0, key === 'q' ? 200 : 255);
|
||||
if (value) query[key] = value;
|
||||
}
|
||||
query.type = selection(params.get('type'), activityTypes);
|
||||
query.source = selection(params.get('source'), activitySources);
|
||||
query.state = selection(params.get('state'), activityStates);
|
||||
// Missing or zero days means all retained history, including removed servers.
|
||||
const days = integer(params.get('days'), 0, 0, 36_500);
|
||||
if (days) query.days = days;
|
||||
return query;
|
||||
}
|
||||
|
||||
export function normalizeActivityQuery(query: ActivityQuery): ActivityQuery {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) if (value !== undefined) params.set(key, String(value));
|
||||
return parseActivityQuery(params);
|
||||
}
|
||||
+2
-2
@@ -130,10 +130,10 @@ export interface HostOperationSummary extends Omit<HostOperation, 'payload' | 'r
|
||||
routine: boolean;
|
||||
}
|
||||
export type HostOperationFilter = 'important' | 'routine' | 'all';
|
||||
export interface HostOperationScope { host?: string; server?: string }
|
||||
export interface HostOperationScope { host?: string; server?: string; operation?: string }
|
||||
export function hostOperationScope(params: URLSearchParams): HostOperationScope {
|
||||
const scope: HostOperationScope = {};
|
||||
for (const key of ['host', 'server'] as const) {
|
||||
for (const key of ['host', 'server', 'operation'] as const) {
|
||||
const value = params.get(key);
|
||||
if (value && value.length <= 512 && !/[\x00-\x1f\x7f]/.test(value)) scope[key] = value;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const origin = 'https://electric-cherry.invalid';
|
||||
const pagePath = /^\/(?:$|operations|account|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/;
|
||||
const pagePath = /^\/(?:$|activity|operations|autoscaling|account|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/;
|
||||
|
||||
/** Only return to application pages, never an external URL or a mutation/API endpoint. */
|
||||
export function safeReturnHref(value: string | null | undefined, fallback: string): string {
|
||||
@@ -30,6 +30,6 @@ export function returnLabel(href: string): string {
|
||||
if (path.startsWith('/matches/')) return 'Back to match';
|
||||
if (path === '/hosts/operations') return 'Back to host operation history';
|
||||
if (path.startsWith('/hosts/')) return 'Back to host';
|
||||
const names: Record<string, string> = { '': 'Fleet', operations: 'Operations', collections: 'Collections', hosts: 'Hosts', capacity: 'Capacity', scripts: 'Scripts', configs: 'Configs', plugins: 'Plugins', 'game-files': 'Game Files', betas: 'Betas', campaigns: 'Campaigns', maintenance: 'Maintenance', provisioning: 'Provisioning', leaderboard: 'Leaderboard', matches: 'Match History', metrics: 'Metrics' };
|
||||
const names: Record<string, string> = { '': 'Servers', activity: 'Activity', operations: 'Activity', autoscaling: 'Autoscaling', collections: 'Collections', hosts: 'Hosts', capacity: 'Capacity', scripts: 'Scripts', configs: 'Configs', plugins: 'Plugins', 'game-files': 'Game Files', betas: 'Betas', campaigns: 'Campaigns', maintenance: 'Maintenance', provisioning: 'Server queues', leaderboard: 'Leaderboard', matches: 'Match History', metrics: 'Metrics' };
|
||||
return `Back to ${names[path.split('/')[1]] ?? 'previous page'}`;
|
||||
}
|
||||
|
||||
@@ -20,8 +20,7 @@ export const navigationGroups: NavigationGroup[] = [
|
||||
{ label: 'Campaigns', href: '/campaigns', shortcut: 'a' }
|
||||
] },
|
||||
{ id: 'activity', label: 'Activity', items: [
|
||||
{ label: 'Operations', href: '/provisioning', shortcut: 'd', matches: path => /^\/(provisioning|deployments)(\/|$)/.test(path) },
|
||||
{ label: 'Host operations', href: '/hosts/operations' },
|
||||
{ label: 'Activity log', href: '/activity', shortcut: 'd', matches: path => path === '/activity' || path === '/operations' || path === '/hosts/operations' || /^\/(provisioning|deployments)(\/|$)/.test(path) },
|
||||
{ label: 'Maintenance', href: '/maintenance', shortcut: 'w', matches: path => /^\/(maintenance|watchdog)(\/|$)/.test(path) }
|
||||
] },
|
||||
{ id: 'players', label: 'Players', items: [
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { normalizeActivityQuery, type ActivityPage, type ActivityQuery, type ActivityRow, type ActivitySource } from '../activity.js';
|
||||
import { serverTitle } from '../ui/server-labels.js';
|
||||
import { db } from './db/index.js';
|
||||
import { ensureHostSchema } from './hosts/schema.js';
|
||||
import { importantOperationSql } from './hosts/observations.js';
|
||||
|
||||
const initialized = new WeakSet<Database.Database>();
|
||||
const quote = (value: string) => `'${value.replaceAll("'", "''")}'`;
|
||||
const hostTitles: Record<string, string> = {
|
||||
'instance.create': 'Create server container', 'instance.rebuild': 'Rebuild server container',
|
||||
'instance.start': 'Start server container', 'instance.restart': 'Restart server container',
|
||||
'instance.stop': 'Stop server container', 'instance.remove': 'Remove server container',
|
||||
'instance.cancel-create': 'Cancel server creation', 'instance.inspect': 'Check server container',
|
||||
'host.inspect': 'Check host', 'host.decommission': 'Remove host', 'runtime.pull': 'Prepare container runtime',
|
||||
'library.download': 'Download game files', 'library.import': 'Import game files',
|
||||
'plutonium.prepare': 'Prepare game update', 'files.stage': 'Stage server files',
|
||||
'files.apply': 'Apply server files', 'files.read': 'Read server files', 'files.list': 'List server files',
|
||||
'files.stat': 'Check server files', 'files.manifest': 'Check file versions', 'files.remove': 'Remove server files',
|
||||
'rcon.status-batch': 'Check game status', 'rcon.execute': 'Game command', 'logs.read': 'Read server logs'
|
||||
};
|
||||
const labelCase = (column: string, labels: Record<string, string>, fallback: string) =>
|
||||
`CASE ${column} ${Object.entries(labels).map(([key, value]) => `WHEN ${quote(key)} THEN ${quote(value)}`).join(' ')} ELSE ${quote(fallback)} END`;
|
||||
|
||||
// Old attempts may no longer be the native_provisioning pointer. Their stable
|
||||
// idempotency keys still identify the same retained controller operation.
|
||||
const lifecycleKey = `CASE
|
||||
WHEN substr(o.idempotency_key,-6)=':stage' THEN substr(o.idempotency_key,1,length(o.idempotency_key)-6)
|
||||
${[':migration-create:', ':migrate-remove:'].map(marker => `WHEN instr(o.idempotency_key,${quote(marker)})>0
|
||||
AND substr(o.idempotency_key,instr(o.idempotency_key,${quote(marker)})+${marker.length})<>''
|
||||
AND substr(o.idempotency_key,instr(o.idempotency_key,${quote(marker)})+${marker.length}) NOT GLOB '*[^0-9]*'
|
||||
THEN substr(o.idempotency_key,1,instr(o.idempotency_key,${quote(marker)})-1)`).join('\n')}
|
||||
ELSE o.idempotency_key END`;
|
||||
|
||||
/** One row per user-level operation or finding; no command payloads or observations are read. */
|
||||
const activitySql = `WITH
|
||||
links AS (
|
||||
SELECT operation_id,kind,source_id,server FROM provisioning_sources
|
||||
UNION SELECT id,substr(triggered_by,1,instr(triggered_by,':')-1),substr(triggered_by,instr(triggered_by,':')+1),server
|
||||
FROM server_deployments WHERE substr(triggered_by,1,instr(triggered_by,':')-1) IN ('maintenance','watchdog','campaign','beta','autoscaling')
|
||||
UNION SELECT provisioning_id,'maintenance',CAST(id AS TEXT),server FROM maintenance_jobs WHERE provisioning_id IS NOT NULL
|
||||
UNION SELECT provisioning_id,'campaign',deployment_id,server FROM deployment_servers WHERE provisioning_id IS NOT NULL
|
||||
UNION SELECT provisioning_id,'beta',CAST(beta_id AS TEXT),server FROM beta_servers WHERE provisioning_id IS NOT NULL
|
||||
UNION SELECT a.operation_id,'watchdog',i.id,i.server FROM watchdog_actions a JOIN watchdog_incidents i ON i.id=a.incident_id
|
||||
),
|
||||
memberships AS (
|
||||
SELECT operation_id,'|' || replace(group_concat(DISTINCT CASE kind WHEN 'watchdog' THEN 'recovery' ELSE kind END),',','|') || '|' AS sources
|
||||
FROM links GROUP BY operation_id
|
||||
),
|
||||
maintenance_links AS (
|
||||
SELECT DISTINCT l.operation_id,m.id,m.state,m.kind,m.target_revision
|
||||
FROM links l JOIN maintenance_jobs m ON l.kind='maintenance' AND l.source_id=CAST(m.id AS TEXT) AND l.server=m.server
|
||||
JOIN server_deployments d ON d.id=l.operation_id
|
||||
WHERE m.provisioning_id=l.operation_id OR (m.provisioning_id IS NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM links newer JOIN server_deployments n ON n.id=newer.operation_id
|
||||
WHERE newer.kind='maintenance' AND newer.source_id=l.source_id AND newer.server=l.server
|
||||
AND (n.started_at>d.started_at OR (n.started_at=d.started_at AND n.rowid>d.rowid))))
|
||||
),
|
||||
maintenance_results AS (
|
||||
SELECT operation_id,MAX(state='failed') AS failed,MAX(state IN ('queued','restarting')) AS pending,
|
||||
MAX(state='cancelled') AS cancelled,MAX(kind='update') AS is_update,MAX(target_revision) AS revision
|
||||
FROM maintenance_links GROUP BY operation_id
|
||||
),
|
||||
covered_host AS (
|
||||
SELECT n.operation_id AS id FROM native_provisioning n JOIN server_deployments d ON d.id=n.deployment_id WHERE n.operation_id IS NOT NULL
|
||||
UNION SELECT n.stage_operation_id FROM native_provisioning n JOIN server_deployments d ON d.id=n.deployment_id WHERE n.stage_operation_id IS NOT NULL
|
||||
UNION SELECT m.remove_operation_id FROM ec_host_migrations m JOIN server_deployments d ON d.id=m.deployment_id WHERE m.remove_operation_id IS NOT NULL
|
||||
),
|
||||
items AS (
|
||||
SELECT 'provisioning:' || d.id AS id,d.started_at AS at,
|
||||
CASE WHEN m.is_update=1 THEN 'update' WHEN d.action='destroy' THEN 'remove' ELSE d.action END AS type,
|
||||
COALESCE(ms.sources,'|provisioning|') AS sources,
|
||||
CASE WHEN d.state IN ('failed','interrupted','cancelled','warning') THEN d.state
|
||||
WHEN m.failed=1 THEN 'failed' WHEN m.pending=1 AND d.state='done' THEN 'verifying'
|
||||
WHEN m.cancelled=1 THEN 'cancelled' ELSE d.state END AS rawState,
|
||||
CASE WHEN m.is_update=1 THEN 'Update game' ELSE ${labelCase('d.action', { create: 'Create server', start: 'Start server', restart: 'Restart server', rebuild: 'Rebuild server', stop: 'Stop server', destroy: 'Remove server' }, 'Server operation')} END AS title,
|
||||
CASE WHEN m.failed=1 AND d.state='done' THEN 'The restart completed, but maintenance verification failed.'
|
||||
WHEN m.pending=1 AND d.state='done' THEN 'The restart completed. Maintenance verification is still in progress.'
|
||||
WHEN m.revision IS NOT NULL THEN 'Target game revision r' || m.revision || '.'
|
||||
WHEN d.state='waiting' THEN 'Waiting for eligibility and available capacity.'
|
||||
WHEN d.state='awaiting-health' THEN 'Checking that the game is responding.' ELSE NULL END AS detail,
|
||||
d.server,d.server_deleted_at AS serverDeletedAt,COALESCE(n.host_id,mg.source_host_id) AS hostId,mg.source_host_id AS secondaryHostId,
|
||||
'provisioning' AS route,d.id AS recordId
|
||||
FROM server_deployments d LEFT JOIN memberships ms ON ms.operation_id=d.id
|
||||
LEFT JOIN maintenance_results m ON m.operation_id=d.id
|
||||
LEFT JOIN native_provisioning p ON p.deployment_id=d.id LEFT JOIN native_instances n ON n.id=p.instance_id
|
||||
LEFT JOIN ec_host_migrations mg ON mg.deployment_id=d.id
|
||||
UNION ALL
|
||||
SELECT 'maintenance:' || m.id,m.requested_at,CASE WHEN m.kind='update' THEN 'update' ELSE 'restart' END,
|
||||
'|maintenance|',m.state,CASE WHEN m.kind='update' THEN 'Update game' WHEN m.kind='schedule' THEN 'Scheduled server maintenance' ELSE 'Server maintenance' END,
|
||||
CASE WHEN m.target_revision IS NOT NULL THEN 'Target game revision r' || m.target_revision || '.' ELSE NULL END,
|
||||
m.server,m.server_deleted_at,NULL,NULL,'maintenance-job',CAST(m.id AS TEXT)
|
||||
FROM maintenance_jobs m WHERE NOT EXISTS (SELECT 1 FROM maintenance_links l WHERE l.id=m.id)
|
||||
UNION ALL
|
||||
SELECT 'recovery:' || i.id,i.first_seen_at,i.kind,'|recovery|',i.state,
|
||||
CASE WHEN i.kind='health' THEN 'Game health finding' ELSE 'Server files differ from the promoted setup' END,
|
||||
CASE WHEN i.state='recovering' THEN 'Recovery is in progress.' WHEN i.state='attention' THEN 'Automatic recovery needs review.'
|
||||
WHEN i.state='resolved' THEN 'The finding was resolved.' WHEN i.state='dismissed' THEN 'The finding was dismissed.' ELSE 'A check found an issue that needs review.' END,
|
||||
i.server,i.server_deleted_at,NULL,NULL,'recovery',i.id
|
||||
FROM watchdog_incidents i
|
||||
UNION ALL
|
||||
SELECT 'campaign:' || d.id || ':' || ds.server,d.started_at,CASE WHEN d.action='drift' THEN 'drift' ELSE 'files' END,
|
||||
CASE WHEN d.triggered_by='watchdog' OR EXISTS (SELECT 1 FROM watchdog_actions a WHERE a.campaign_id=d.id)
|
||||
THEN '|campaign|recovery|' ELSE '|campaign|' END,ds.restart_state,
|
||||
CASE d.action WHEN 'promote' THEN 'Promote files' WHEN 'rollback' THEN 'Roll back files' ELSE 'Reconcile files' END,
|
||||
CAST(ds.files_changed AS TEXT) || CASE WHEN ds.files_changed=1 THEN ' file changed.' ELSE ' files changed.' END,
|
||||
ds.server,ds.server_deleted_at,NULL,NULL,'campaign',d.id
|
||||
FROM deployments d JOIN deployment_servers ds ON ds.deployment_id=d.id
|
||||
UNION ALL
|
||||
SELECT 'beta:' || b.id || ':' || bs.server,b.opened_at,'beta','|beta|',
|
||||
CASE WHEN b.state IN ('promoted','rolled-back','cancelled') THEN b.state ELSE bs.stage END,
|
||||
'Beta test #' || b.id,NULL,bs.server,bs.server_deleted_at,NULL,NULL,'beta',CAST(b.id AS TEXT)
|
||||
FROM betas b JOIN beta_servers bs ON bs.beta_id=b.id
|
||||
UNION ALL
|
||||
SELECT 'host:' || o.id,o.created_at,'host','|host|',
|
||||
CASE WHEN o.state='completed' AND o.routine=1 AND o.observation_success=0 THEN 'observation-failed'
|
||||
WHEN o.state='completed' AND COALESCE(o.error,'')<>'' THEN 'warning' ELSE o.state END,
|
||||
${labelCase('o.kind', hostTitles, 'Host operation')},
|
||||
CASE WHEN o.routine=1 AND o.state='completed' AND o.observation_success=0 THEN 'The command completed, but the check reported a problem.'
|
||||
WHEN o.state='unknown' THEN 'The host has not confirmed the result.' ELSE NULL END,
|
||||
COALESCE(o.server_name,n.server),s.destroyed_at,o.host_id,NULL,'host',o.id
|
||||
FROM ec_host_operations o LEFT JOIN native_instances n ON n.id=o.instance_id
|
||||
LEFT JOIN servers s ON s.name=COALESCE(o.server_name,n.server)
|
||||
WHERE o.history_expired_at IS NULL AND ${importantOperationSql}
|
||||
AND NOT EXISTS (SELECT 1 FROM covered_host c WHERE c.id=o.id)
|
||||
AND NOT (o.kind IN ('files.stage','instance.create','instance.rebuild','instance.start','instance.restart','instance.stop','instance.remove','instance.cancel-create')
|
||||
AND EXISTS (SELECT 1 FROM server_deployments d WHERE d.id=${lifecycleKey}))
|
||||
),
|
||||
normalized AS (
|
||||
SELECT i.*,h.name AS hostName,
|
||||
CASE WHEN rawState IN ('queued','pending','waiting','held','awaiting-empty','awaiting-match') THEN 'queued'
|
||||
WHEN rawState IN ('running','accepted','preparing','staging','testing','observing','deploying','restarting','recovering','awaiting-health','verifying','rolling-back') THEN 'running'
|
||||
WHEN rawState IN ('done','completed','resolved','ready','ready-promote','promoted','rolled-back') THEN 'succeeded'
|
||||
WHEN rawState IN ('failed','interrupted','observation-failed') THEN 'failed'
|
||||
WHEN rawState IN ('cancelled','dismissed','skipped') THEN 'cancelled'
|
||||
WHEN rawState IN ('manual','attention','open') THEN 'attention'
|
||||
WHEN rawState='warning' THEN 'warning' ELSE 'unknown' END AS state
|
||||
FROM items i LEFT JOIN ec_hosts h ON h.id=i.hostId
|
||||
)
|
||||
`;
|
||||
|
||||
type SqlRow = Omit<ActivityRow, 'source' | 'sources' | 'href'> & { sources: string; route: string; recordId: string; secondaryHostId: string | null };
|
||||
const sourceOrder: ActivitySource[] = ['maintenance', 'recovery', 'campaign', 'beta', 'autoscaling', 'provisioning', 'host'];
|
||||
function present(row: SqlRow): ActivityRow {
|
||||
const sources = sourceOrder.filter(source => row.sources.includes(`|${source}|`));
|
||||
const href = row.route === 'maintenance-job' ? `/maintenance?view=overview&job=${encodeURIComponent(row.recordId)}#job-${row.recordId}`
|
||||
: row.route === 'recovery' ? `/maintenance/${encodeURIComponent(row.recordId)}`
|
||||
: row.route === 'host' ? `/hosts/operations?operation=${encodeURIComponent(row.recordId)}&filter=all`
|
||||
: `/${row.route === 'campaign' ? 'campaigns' : row.route === 'beta' ? 'betas' : 'provisioning'}/${encodeURIComponent(row.recordId)}`;
|
||||
return { id: row.id, at: row.at, type: row.type, source: sources[0] ?? 'provisioning', sources,
|
||||
state: row.state, rawState: row.rawState, title: row.title, detail: row.detail,
|
||||
server: row.server, serverDeletedAt: row.serverDeletedAt, hostId: row.hostId, hostName: row.hostName, href };
|
||||
}
|
||||
|
||||
/** All predicates, totals and pagination operate on the same retained SQL union. */
|
||||
export function activityHistory(input: ActivityQuery = {}, now = Date.now()): ActivityPage {
|
||||
const query = normalizeActivityQuery(input);
|
||||
const handle = db();
|
||||
ensureHostSchema(handle);
|
||||
if (!initialized.has(handle)) {
|
||||
handle.function('activity_server_title', { deterministic: true }, (name: unknown) => typeof name === 'string' ? serverTitle(name) : '');
|
||||
initialized.add(handle);
|
||||
}
|
||||
const conditions: string[] = [];
|
||||
const bindings: Record<string, string | number> = {};
|
||||
for (const key of ['server', 'type', 'state'] as const) if (query[key]) { conditions.push(`${key}=@${key}`); bindings[key] = query[key]; }
|
||||
if (query.host) { conditions.push('(hostId=@host OR secondaryHostId=@host)'); bindings.host = query.host; }
|
||||
if (query.source) {
|
||||
// Maintenance is the user-facing umbrella; Recovery remains available as a narrower filter.
|
||||
conditions.push(query.source === 'maintenance' ? "(instr(sources,'|maintenance|')>0 OR instr(sources,'|recovery|')>0)" : 'instr(sources,@source)>0');
|
||||
if (query.source !== 'maintenance') bindings.source = `|${query.source}|`;
|
||||
}
|
||||
if (query.days) { conditions.push('julianday(at)>=julianday(@since)'); bindings.since = new Date(now - query.days * 86_400_000).toISOString(); }
|
||||
if (query.q) {
|
||||
conditions.push("instr(lower(COALESCE(server,'') || ' ' || activity_server_title(server) || ' ' || COALESCE(hostName,'') || ' ' || COALESCE(hostId,'') || ' ' || title || ' ' || COALESCE(detail,'') || ' ' || id),@q)>0");
|
||||
bindings.q = query.q.toLocaleLowerCase();
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const limit = query.limit!;
|
||||
return handle.transaction(() => {
|
||||
const { total } = handle.prepare(`${activitySql} SELECT COUNT(*) AS total FROM normalized ${where}`).get(bindings) as { total: number };
|
||||
const offset = Math.min(query.offset!, Math.max(0, Math.ceil(total / limit) - 1) * limit);
|
||||
const rows = handle.prepare(`${activitySql} SELECT id,at,type,sources,state,rawState,title,detail,server,serverDeletedAt,hostId,hostName,secondaryHostId,route,recordId
|
||||
FROM normalized ${where} ORDER BY julianday(at) DESC,id DESC LIMIT @limit OFFSET @offset`)
|
||||
.all({ ...bindings, limit, offset }) as SqlRow[];
|
||||
return { rows: rows.map(present), total, limit, offset, query: { ...query, offset } };
|
||||
})();
|
||||
}
|
||||
@@ -438,11 +438,15 @@ export function listHostOperationHistory(page = 1, filter: HostOperationFilter =
|
||||
const parameters: string[] = [];
|
||||
if (scope.host) { conditions.push('o.host_id=?'); parameters.push(scope.host); }
|
||||
if (scope.server) { conditions.push('o.server_name=?'); parameters.push(scope.server); }
|
||||
if (scope.operation) { conditions.push('o.id=?'); parameters.push(scope.operation); }
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
return handle.transaction((): HostOperationHistoryPage => {
|
||||
const countColumn = filter === 'all' ? 'total' : filter;
|
||||
const total = (handle.prepare(`SELECT ${countColumn} AS total FROM ec_host_operation_history_counts WHERE host_id=? AND server_name=?`)
|
||||
.get(scope.host ?? '', scope.server ?? '') as { total: number } | undefined)?.total ?? 0;
|
||||
// An exact operation seeks its unique ID instead of paging through history.
|
||||
const total = scope.operation
|
||||
? (handle.prepare(`SELECT 1 AS total FROM ec_host_operations o ${where}`).get(...parameters) as { total: number } | undefined)?.total ?? 0
|
||||
: (handle.prepare(`SELECT ${countColumn} AS total FROM ec_host_operation_history_counts WHERE host_id=? AND server_name=?`)
|
||||
.get(scope.host ?? '', scope.server ?? '') as { total: number } | undefined)?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const currentPage = Math.min(totalPages, Number.isSafeInteger(page) && page > 0 ? page : 1);
|
||||
const operations = handle.prepare(`SELECT o.id,o.host_id AS hostId,h.name AS hostName,o.kind,
|
||||
|
||||
@@ -3,8 +3,8 @@ import { watchdogOverview, watchdogTick } from './recovery.js';
|
||||
import { maintenanceTick, plutoniumStatus } from './runtime.js';
|
||||
import type { MaintenanceOverview } from '../../maintenance.js';
|
||||
|
||||
export function maintenanceOverview(): MaintenanceOverview {
|
||||
return { policies: maintenancePolicies(), recovery: watchdogOverview(), runtime: plutoniumStatus() };
|
||||
export function maintenanceOverview(selectedJob: number | null = null): MaintenanceOverview {
|
||||
return { policies: maintenancePolicies(), recovery: watchdogOverview(), runtime: plutoniumStatus(selectedJob) };
|
||||
}
|
||||
export async function runMaintenance(): Promise<void> {
|
||||
// One failing observer must not prevent the other capabilities from reconciling.
|
||||
|
||||
@@ -18,14 +18,14 @@ export function saveMaintenanceSchedule(enabled: boolean, time: string, timeZone
|
||||
const nextAt = nextMaintenanceAt(time, new Date(), timeZone);
|
||||
writeSetting('plutonium_schedule', { enabled, time, timeZone, nextAt: enabled ? nextAt : null });
|
||||
}
|
||||
export function plutoniumStatus(): PlutoniumStatus {
|
||||
export function plutoniumStatus(selectedJob: number | null = null): PlutoniumStatus {
|
||||
return {
|
||||
release: release(), schedule: schedule(), servers: runningVersions(),
|
||||
jobs: db().prepare(`SELECT m.*, d.stage AS operation_stage, d.state AS operation_state
|
||||
FROM maintenance_jobs m LEFT JOIN server_deployments d ON d.id = m.provisioning_id
|
||||
WHERE m.state IN ('queued','restarting')
|
||||
OR (m.state = 'failed' AND NOT EXISTS (SELECT 1 FROM maintenance_jobs n WHERE n.server = m.server AND n.kind = m.kind AND n.id > m.id))
|
||||
OR m.id IN (SELECT id FROM maintenance_jobs ORDER BY id DESC LIMIT 100) ORDER BY m.id DESC`).all() as MaintenanceJob[]
|
||||
OR m.id IN (SELECT id FROM maintenance_jobs ORDER BY id DESC LIMIT 100) OR m.id = ? ORDER BY m.id DESC`).all(selectedJob) as MaintenanceJob[]
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { parseActivityQuery, type ActivityQuery, type ActivitySource } from '../activity.js';
|
||||
import { matchStartedLabel } from './match-history.js';
|
||||
|
||||
export interface ActivityScope { server?: string; source?: ActivitySource }
|
||||
const keys = ['server', 'host', 'type', 'source', 'state', 'days', 'q', 'limit', 'offset'] as const;
|
||||
|
||||
/** Scoped views always retain their server/source, including after reset or a pasted URL. */
|
||||
export function activityQuery(url: URL, scope: ActivityScope = {}): ActivityQuery {
|
||||
const params = new URLSearchParams(url.searchParams);
|
||||
if (scope.server !== undefined) params.set('server', scope.server);
|
||||
if (scope.source !== undefined) params.set('source', scope.source);
|
||||
return parseActivityQuery(params);
|
||||
}
|
||||
|
||||
export function activityApiHref(url: URL, scope: ActivityScope = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(activityQuery(url, scope))) {
|
||||
if (value !== undefined) params.set(key, String(value));
|
||||
}
|
||||
return `/api/activity?${params}`;
|
||||
}
|
||||
|
||||
/** Preserve the containing tab and return context while paging or changing filters. */
|
||||
export function activityHistoryHref(url: URL, changes: Partial<Record<keyof ActivityQuery, string>>): string {
|
||||
const next = new URL(url);
|
||||
if (!Object.hasOwn(changes, 'offset')) next.searchParams.delete('offset');
|
||||
for (const key of keys) {
|
||||
if (changes[key] === undefined) continue;
|
||||
const value = changes[key]?.trim();
|
||||
if (value && !(['offset', 'days'].includes(key) && value === '0')) next.searchParams.set(key, value);
|
||||
else next.searchParams.delete(key);
|
||||
}
|
||||
return next.pathname + next.search + next.hash;
|
||||
}
|
||||
|
||||
/** Recent entries scan as relative time; exact timestamps stay on the time element. */
|
||||
export function activityTimeLabel(value: string, zone: string, now: number): string {
|
||||
const timestamp = Date.parse(/Z$|[+-]\d\d:\d\d$/.test(value) ? value : value.replace(' ', 'T') + 'Z');
|
||||
if (!Number.isFinite(timestamp)) return 'Time unavailable';
|
||||
const age = now - timestamp;
|
||||
if (age < 0) return matchStartedLabel(value, zone, new Date(now));
|
||||
if (age < 5_000) return 'Just now';
|
||||
if (age < 60_000) return `${Math.floor(age / 1000)} s ago`;
|
||||
if (age < 3_600_000) return `${Math.floor(age / 60_000)} min ago`;
|
||||
if (age < 86_400_000) return `${Math.floor(age / 3_600_000)} h ago`;
|
||||
return matchStartedLabel(value, zone, new Date(now));
|
||||
}
|
||||
@@ -17,7 +17,7 @@ function matches(query: string, ...values: Array<string | null | undefined>) {
|
||||
}
|
||||
|
||||
export function palettePageResults(role: UserRole, query: string): PaletteItem[] {
|
||||
const actions: PaletteItem[] = role === 'viewer' ? [] : [{ key: 'action:create', group: 'Actions', label: 'Create server', detail: 'Choose a map and host', href: '/?action=create' }];
|
||||
const actions: PaletteItem[] = role === 'viewer' ? [] : [{ key: 'action:create', group: 'Actions', label: 'Create server', detail: 'Choose a map and host', href: '/?action=create' }, { key: 'action:manage', group: 'Actions', label: 'Manage servers', detail: 'Server queues and lifecycle actions', href: '/provisioning' }];
|
||||
if (role === 'admin') actions.push({ key: 'action:add-host', group: 'Actions', label: 'Add host', detail: 'Enroll a host agent', href: '/hosts?action=add' });
|
||||
return [...actions.filter(item => matches(query, item.label, item.detail)),
|
||||
...visibleNavigation(role).flatMap(group => group.items.filter(item => matches(query, item.label, group.label)).map(item => ({
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { activityTypes, activitySources, activityStates, type ActivityPage, type ActivityQuery, type ActivitySource } from '$lib/activity.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { activityApiHref, activityHistoryHref, activityQuery, activityTimeLabel } from '$ui/activity-history.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import { fleet } from '$ui/stores/fleet.js';
|
||||
import { uiNow } from '$ui/stores/servers.js';
|
||||
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import Dialog from './primitives/Dialog.svelte';
|
||||
import EmptyState from './primitives/EmptyState.svelte';
|
||||
import StatusBadge from './primitives/StatusBadge.svelte';
|
||||
import Freshness from './Freshness.svelte';
|
||||
import HistoricalServerIdentity from './HistoricalServerIdentity.svelte';
|
||||
|
||||
let { server, source, initial }: { server?: string; source?: ActivitySource; initial?: ActivityPage } = $props();
|
||||
const id = $props.id();
|
||||
const headingId = `${id}-activity-heading`;
|
||||
let data = $state<ActivityPage | undefined>(untrack(() => initial));
|
||||
let loading = $state(!untrack(() => initial));
|
||||
let error = $state('');
|
||||
let retry = $state(0);
|
||||
let filtersOpen = $state(false);
|
||||
let checkedAt = $state<number | null>(untrack(() => initial ? Date.now() : null));
|
||||
let results = $state<HTMLOListElement>();
|
||||
let search = $state(untrack(() => activityQuery($page.url, { server, source }).q ?? ''));
|
||||
let pendingUrl: URL | undefined;
|
||||
let navigationId = 0;
|
||||
const filters = $derived(activityQuery($page.url, { server, source }));
|
||||
const apiUrl = $derived(activityApiHref($page.url, { server, source }));
|
||||
let loadedUrl = $state(untrack(() => initial ? apiUrl : ''));
|
||||
const filterCount = $derived([filters.type, filters.state, source === undefined ? filters.source : '', server === undefined ? filters.server : '', filters.host, filters.days, filters.q].filter(Boolean).length);
|
||||
const serverOptions = $derived.by(() => {
|
||||
const names = new Set([...$fleet.servers.map(item => item.name), ...(data?.rows.flatMap(row => row.server ? [row.server] : []) ?? [])]);
|
||||
if (filters.server) names.add(filters.server);
|
||||
return [...names].sort((a, b) => serverTitle(a).localeCompare(serverTitle(b)));
|
||||
});
|
||||
const hostOptions = $derived.by(() => {
|
||||
const hosts = new Map<string, string>();
|
||||
for (const item of $fleet.servers) if (item.host) hosts.set(item.host.id, item.host.name);
|
||||
for (const row of data?.rows ?? []) if (row.hostId) hosts.set(row.hostId, row.hostName ?? 'Historical host');
|
||||
if (filters.host && !hosts.has(filters.host)) hosts.set(filters.host, 'Selected host');
|
||||
return [...hosts].sort((a, b) => a[1].localeCompare(b[1]));
|
||||
});
|
||||
|
||||
$effect(() => { search = filters.q ?? ''; });
|
||||
$effect(() => {
|
||||
const report = initial;
|
||||
if (report) untrack(() => { data = report; loadedUrl = apiUrl; loading = false; error = ''; checkedAt = Date.now(); });
|
||||
});
|
||||
$effect(() => {
|
||||
const url = apiUrl;
|
||||
retry;
|
||||
untrack(() => { if (loadedUrl !== url) loading = true; error = ''; });
|
||||
return pollVisible(async signal => {
|
||||
try {
|
||||
const response = await fetch(url, { signal, cache: 'no-store' });
|
||||
if (!response.ok) throw new Error('Could not load activity.');
|
||||
const body: ActivityPage = await response.json();
|
||||
if (signal.aborted || apiUrl !== url) return;
|
||||
// Keep a focused operation link in place when newer rows arrive.
|
||||
if (loadedUrl !== url || !results?.contains(document.activeElement)) { data = body; checkedAt = Date.now(); }
|
||||
loadedUrl = url; loading = false; error = '';
|
||||
} catch (cause) {
|
||||
if (signal.aborted || apiUrl !== url) return;
|
||||
error = cause instanceof Error ? cause.message : 'Could not load activity.';
|
||||
loading = false;
|
||||
return false;
|
||||
}
|
||||
}, 10_000, untrack(() => loadedUrl !== url || retry > 0));
|
||||
});
|
||||
function update(changes: Partial<Record<keyof ActivityQuery, string>>) {
|
||||
const href = activityHistoryHref(pendingUrl ?? $page.url, changes);
|
||||
pendingUrl = new URL(href, $page.url);
|
||||
const id = ++navigationId;
|
||||
void goto(href, { noScroll: true, keepFocus: true })
|
||||
.catch(() => { error = 'Could not update activity filters. Please try again.'; })
|
||||
.finally(() => { if (id === navigationId) pendingUrl = undefined; });
|
||||
}
|
||||
function reset() { update({ type: '', state: '', source: '', server: '', host: '', days: '', q: '' }); }
|
||||
function submitSearch() { update({ q: search.trim() }); }
|
||||
</script>
|
||||
|
||||
<section class="activity-log" aria-labelledby={headingId}>
|
||||
<h2 class="sr-only" id={headingId}>Activity history</h2>
|
||||
{#snippet filterFields(suffix: string)}
|
||||
<div class="filters">
|
||||
<label for={`activity-type${suffix}`}>Type<select id={`activity-type${suffix}`} value={filters.type ?? ''} onchange={event => update({ type: event.currentTarget.value })}><option value="">All types</option>{#each Object.entries(activityTypes) as [value, label]}<option {value}>{label}</option>{/each}</select></label>
|
||||
<label for={`activity-state${suffix}`}>State<select id={`activity-state${suffix}`} value={filters.state ?? ''} onchange={event => update({ state: event.currentTarget.value })}><option value="">All states</option>{#each Object.entries(activityStates) as [value, label]}<option {value}>{label}</option>{/each}</select></label>
|
||||
{#if source === undefined}<label for={`activity-source${suffix}`}>Source<select id={`activity-source${suffix}`} value={filters.source ?? ''} onchange={event => update({ source: event.currentTarget.value })}><option value="">All sources</option>{#each Object.entries(activitySources) as [value, label]}<option {value}>{label}</option>{/each}</select></label>{/if}
|
||||
{#if server === undefined}<label for={`activity-server${suffix}`}>Server<select id={`activity-server${suffix}`} value={filters.server ?? ''} onchange={event => update({ server: event.currentTarget.value })}><option value="">All servers</option>{#each serverOptions as name}<option value={name}>{serverTitle(name)}</option>{/each}</select></label>{/if}
|
||||
<label for={`activity-host${suffix}`}>Host<select id={`activity-host${suffix}`} value={filters.host ?? ''} onchange={event => update({ host: event.currentTarget.value })}><option value="">All hosts</option>{#each hostOptions as [value, label]}<option {value}>{label}</option>{/each}</select></label>
|
||||
<label for={`activity-days${suffix}`}>Date range<select id={`activity-days${suffix}`} value={filters.days ?? 0} onchange={event => update({ days: event.currentTarget.value })}><option value="0">All retained history</option><option value="1">Last 24 hours</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option>{#if filters.days && ![1, 7, 30, 90].includes(filters.days)}<option value={filters.days}>Last {filters.days} days</option>{/if}</select></label>
|
||||
<form class="search" role="search" onsubmit={event => { event.preventDefault(); submitSearch(); }}><label for={`activity-search${suffix}`}>Search activity<input id={`activity-search${suffix}`} type="search" bind:value={search} maxlength="200" placeholder="Server, host, or activity…" /></label><Button type="submit" size="sm">Search</Button></form>
|
||||
</div>
|
||||
{/snippet}
|
||||
<div class="desktop-filters">{@render filterFields('')}</div>
|
||||
<div class="mobile-filters"><span>{data?.total ?? '—'} {data?.total === 1 ? 'activity' : 'activities'}{source ? ` · ${activitySources[source]}` : ''}</span><Button size="sm" onclick={() => filtersOpen = true}>Filters{filterCount ? ` · ${filterCount}` : ''}</Button></div>
|
||||
<Dialog bind:open={filtersOpen} title="Activity filters" description="Choose the activity to show." drawer>
|
||||
{@render filterFields('-mobile')}
|
||||
{#snippet actions()}<Button variant="ghost" onclick={reset}>Clear filters</Button><Button variant="primary" onclick={() => { if (search.trim() !== (filters.q ?? '')) submitSearch(); filtersOpen = false; }}>Show results</Button>{/snippet}
|
||||
</Dialog>
|
||||
<div class="context"><span>Newest first · Times in {$timeZone}</span>{#if filterCount}<button class="text-button" onclick={reset}>Clear filters</button>{/if}{#if !error}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 10 seconds" />{/if}</div>
|
||||
{#if error}<div class="error" role="alert"><span>{error}{loadedUrl === apiUrl && data ? ' Showing the last successful update.' : ''}</span><Button size="sm" onclick={() => retry++}>Retry</Button></div>{/if}
|
||||
{#if loading}<EmptyState title="Loading activity…" />
|
||||
{:else if loadedUrl !== apiUrl && error}<EmptyState title="Activity unavailable" description="Retry to load this selection." />
|
||||
{:else if data?.rows.length}
|
||||
<ol class="activity-list" bind:this={results} aria-label="Newest activity first">
|
||||
{#each data.rows as row (row.id)}
|
||||
<li class="activity-row">
|
||||
<div class="event"><h3><a href={contextHref(row.href, $page.url)} aria-label={`${row.title}${row.server ? ` · ${serverTitle(row.server)}` : row.hostName ? ` · ${row.hostName}` : ''} · ${$timeFormat.period(row.at)}`}>{row.title}</a></h3>{#if row.detail}<p>{row.detail}</p>{/if}
|
||||
<div class="resources">{#if row.server && server === undefined}<HistoricalServerIdentity name={row.server} deletedAt={row.serverDeletedAt} href={contextHref(`/servers/${encodeURIComponent(row.server)}`, $page.url)} />{/if}
|
||||
{#if row.hostName}<span class="host-name">{row.hostName}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="outcome"><StatusBadge state={row.state === 'succeeded' ? 'done' : row.state} /><time datetime={row.at} title={$timeFormat.dateTime(row.at)} aria-label={$timeFormat.dateTime(row.at)}>{activityTimeLabel(row.at, $timeZone, $uiNow)}</time>
|
||||
<span class="sources">{(row.sources.length ? row.sources : [row.source]).map(item => activitySources[item]).join(' · ')}</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{:else if !error}<EmptyState title={filterCount ? 'No matching activity' : 'No activity recorded yet'} description={filterCount ? 'Try another type, state, server, host, or date range.' : 'Server operations, file changes, maintenance, and host work appear here.'}>
|
||||
{#snippet actions()}{#if filterCount}<Button size="sm" onclick={reset}>Clear filters</Button>{/if}{/snippet}
|
||||
</EmptyState>{/if}
|
||||
{#if data && !loading && loadedUrl === apiUrl && (data.total > 0 || data.offset > 0)}
|
||||
<div class="pagination"><span>{data.rows.length ? `${data.offset + 1}–${data.offset + data.rows.length} of ${data.total}` : 'No activities on this page'}</span>
|
||||
<label>Per page<select aria-label="Activities per page" value={data.limit} onchange={event => update({ limit: event.currentTarget.value })}>{#each [...new Set([25, 50, 100, data.limit])].sort((a, b) => a - b) as size}<option value={size}>{size}</option>{/each}</select></label>
|
||||
{#if data.total > data.limit || data.offset > 0}<nav aria-label="Activity pages"><Button size="sm" disabled={data.offset === 0} href={activityHistoryHref($page.url, { offset: String(Math.max(0, data.offset - data.limit)) })}>Previous</Button><Button size="sm" disabled={data.offset + data.limit >= data.total} href={activityHistoryHref($page.url, { offset: String(data.offset + data.limit) })}>Next</Button></nav>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.activity-log { display: grid; gap: var(--space-4); min-width: 0; }
|
||||
.filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); align-items: end; gap: var(--space-3); }
|
||||
label { display: grid; gap: 6px; color: var(--text-secondary); font-size: var(--text-xs); min-width: 0; }
|
||||
select, input { width: 100%; min-height: 40px; padding: 8px 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; font-size: var(--text-sm); }
|
||||
.search { grid-column: span 2; display: flex; align-items: end; gap: var(--space-2); min-width: 0; }
|
||||
.search label { flex: 1; }
|
||||
.context, .pagination { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-4); font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.context > :last-child { margin-left: auto; }
|
||||
.text-button { padding: 4px 0; border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font: inherit; }
|
||||
.activity-list { padding: 0; margin: 0; list-style: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
|
||||
.activity-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(180px, auto); gap: var(--space-3); padding: var(--space-4); }
|
||||
.activity-row + .activity-row { border-top: 1px solid var(--border-subtle); }
|
||||
.activity-row:focus-within { background: var(--surface-raised); }
|
||||
h3, p { margin: 0; }
|
||||
h3 { font-size: var(--text-sm); font-weight: var(--weight-medium); }
|
||||
a { color: var(--accent-primary-hover); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
a:focus-visible, button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
.event { min-width: 0; overflow-wrap: anywhere; }
|
||||
.event p { margin-top: var(--space-1); color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
|
||||
.resources { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1) var(--space-3); margin-top: var(--space-2); font-size: var(--text-xs); }
|
||||
.host-name, .sources, time { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.outcome { display: flex; flex-direction: column; align-items: flex-end; gap: var(--space-2); text-align: right; }
|
||||
time { font-variant-numeric: tabular-nums; }
|
||||
.sources { max-width: 32ch; overflow-wrap: anywhere; }
|
||||
.pagination { justify-content: space-between; }
|
||||
.pagination label { display: flex; align-items: center; gap: var(--space-2); }
|
||||
.pagination select { width: auto; min-height: 36px; }
|
||||
.pagination nav { display: flex; gap: var(--space-2); }
|
||||
.error { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); color: var(--state-warning); font-size: var(--text-sm); }
|
||||
.mobile-filters { display: none; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 640px) {
|
||||
.desktop-filters { display: none; }
|
||||
.mobile-filters { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); font-size: var(--text-sm); }
|
||||
.filters { grid-template-columns: minmax(0, 1fr); }
|
||||
.search { grid-column: auto; }
|
||||
.context { gap: var(--space-1) var(--space-2); }
|
||||
.context > :last-child { margin-left: 0; }
|
||||
.activity-row { grid-template-columns: minmax(0, 1fr); padding: var(--space-3); gap: var(--space-2); }
|
||||
.outcome { flex-direction: row; flex-wrap: wrap; align-items: center; text-align: left; }
|
||||
.sources { max-width: none; }
|
||||
.pagination { gap: var(--space-3); }
|
||||
.pagination nav { margin-left: auto; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { FleetOperation } from '$lib/fleet-overview.js';
|
||||
|
||||
export interface InstanceCampaign {
|
||||
id: string;
|
||||
action: string;
|
||||
started_at: string;
|
||||
servers: Array<{ server: string; restart_state: string; error: string | null; files_changed: number }>;
|
||||
}
|
||||
|
||||
/** Merge overlapping campaign snapshots without hiding distinct provisioning records. */
|
||||
export function instanceActivity(name: string, operations: FleetOperation[], campaigns: InstanceCampaign[]) {
|
||||
const campaignLinks = new Set(campaigns.map(item => `/campaigns/${encodeURIComponent(item.id)}`));
|
||||
return [
|
||||
...operations.filter(op => op.server === name && !(op.kind === 'campaign' && campaignLinks.has(op.href))).map(op => ({
|
||||
key: op.key, at: op.startedAt,
|
||||
label: op.activityLabel ?? (op.label === 'Complete' ? op.detail : op.label),
|
||||
state: op.resultState ?? op.state,
|
||||
source: op.kind === 'deployment' ? 'Provisioning' : op.kind.charAt(0).toUpperCase() + op.kind.slice(1),
|
||||
href: op.href, detail: op.state === 'attention' ? op.detail : null
|
||||
})),
|
||||
...campaigns.flatMap(campaign => {
|
||||
const result = campaign.servers.find(item => item.server === name);
|
||||
if (!result) return [];
|
||||
const action = campaign.action === 'promote' ? 'Promote files' : campaign.action === 'rollback' ? 'Roll back files' : 'Reconcile files';
|
||||
return [{ key: `history-${campaign.id}`, at: campaign.started_at,
|
||||
label: `${action} · ${result.files_changed} ${result.files_changed === 1 ? 'file' : 'files'}`,
|
||||
state: result.restart_state, source: 'Campaign', href: `/campaigns/${encodeURIComponent(campaign.id)}`, detail: result.error }];
|
||||
})
|
||||
].sort((a, b) => Date.parse(b.at) - Date.parse(a.at) || a.key.localeCompare(b.key));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { parseActivityQuery } from '$lib/activity.js';
|
||||
import { activityHistory } from '$lib/server/activity.js';
|
||||
|
||||
export function load({ url }: { url: URL }) {
|
||||
return { activity: activityHistory(parseActivityQuery(url.searchParams)) };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import ActivityLog from '$ui/components/ActivityLog.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
let { data }: { data: import('./$types').PageData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Activity · Electric Cherry</title></svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<SectionHeader title="Activity" subtitle="Server operations, releases, maintenance, and host work in one history.">
|
||||
{#snippet actions()}{#if data.user && data.user.role !== 'viewer'}<Button href="/provisioning">Manage servers</Button>{/if}{/snippet}
|
||||
</SectionHeader>
|
||||
<ActivityLog initial={data.activity} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page { display: grid; gap: var(--space-6); min-width: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { parseActivityQuery } from '$lib/activity.js';
|
||||
import { activityHistory } from '$lib/server/activity.js';
|
||||
|
||||
export function GET({ url }: { url: URL }) {
|
||||
return json(activityHistory(parseActivityQuery(url.searchParams)), { headers: { 'Cache-Control': 'no-store' } });
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export function GET({ request }: { request: Request }) {
|
||||
if (!Number.isSafeInteger(page) || page < 1) error(400, 'Invalid operation history page.');
|
||||
if (!isHostOperationFilter(filter)) error(400, 'Invalid operation history filter.');
|
||||
const scope = hostOperationScope(params);
|
||||
for (const key of ['host', 'server'] as const) {
|
||||
for (const key of ['host', 'server', 'operation'] as const) {
|
||||
if (params.has(key) && params.get(key) !== scope[key]) error(400, 'Invalid operation history scope.');
|
||||
}
|
||||
return json(listHostOperationHistory(page, filter, scope), { headers: { 'Cache-Control': 'no-store' } });
|
||||
|
||||
@@ -6,7 +6,11 @@ import { cancelMaintenance, checkPlutonium, queueUpdates, retryMaintenance, save
|
||||
import { validTimeZone } from '$lib/time-zone.js';
|
||||
import { kick } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
export function GET() { return json(maintenanceOverview()); }
|
||||
export function GET(event?: { url: URL }) {
|
||||
const job = event?.url.searchParams.get('job');
|
||||
if (job !== undefined && job !== null && (!/^[1-9]\d*$/.test(job) || !Number.isSafeInteger(Number(job)))) return json({ error: 'Choose a valid maintenance request.' }, { status: 400 });
|
||||
return json(maintenanceOverview(job ? Number(job) : null));
|
||||
}
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) return json({ error: 'Invalid request.' }, { status: 400 });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { getHost, listHostOperationHistory } from '$lib/server/hosts/index.js';
|
||||
import { hostOperationScope, isHostOperationFilter } from '$lib/hosts.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
@@ -8,6 +8,7 @@ export const load: PageServerLoad = ({ url }) => {
|
||||
const requestedFilter = url.searchParams.get('filter');
|
||||
const filter = isHostOperationFilter(requestedFilter) ? requestedFilter : 'important';
|
||||
const scope = hostOperationScope(url.searchParams);
|
||||
if (url.searchParams.has('operation') && !scope.operation) error(400, 'Invalid host operation.');
|
||||
const history = listHostOperationHistory(Number(requestedPage ?? 1), filter, scope);
|
||||
if ((requestedPage !== null && requestedPage !== String(history.page))
|
||||
|| (requestedFilter !== null && requestedFilter !== filter)) {
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
{ id: 'all', label: 'All operations', description: 'Every recorded remote operation.', empty: 'No remote operations yet.' }
|
||||
];
|
||||
const selectedFilter = $derived(filters.find(filter => filter.id === history.filter)!);
|
||||
const allOperationsHref = $derived.by(() => {
|
||||
const params = new URLSearchParams($page.url.searchParams);
|
||||
params.delete('operation'); params.delete('page');
|
||||
return `/hosts/operations${params.size ? `?${params}` : ''}`;
|
||||
});
|
||||
function pageHref(pageNumber: number, filter: HostOperationFilter = history.filter) {
|
||||
const params = new URLSearchParams($page.url.searchParams);
|
||||
params.set('filter', filter); params.delete('page');
|
||||
@@ -69,7 +74,8 @@
|
||||
<SectionHeader title="Host operation history" eyebrow="Operations" subtitle="Remote operations, newest first. Filtered views retain their host and server context.">
|
||||
{#snippet actions()}<Button disabled={refreshing || changingPage} onclick={() => void refresh()}>{refreshing ? 'Refreshing…' : 'Refresh'}</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
{#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/hosts" />{/if}
|
||||
{#if data.scope.operation || $page.url.searchParams.has('returnTo')}<BackLink fallback={data.scope.operation ? '/activity' : '/hosts'} />{/if}
|
||||
{#if data.scope.operation}<div class="selected-operation"><div><strong>Selected host operation</strong><code>{data.scope.operation}</code></div><a href={allOperationsHref}>View all operations</a></div>{/if}
|
||||
{#if data.scope.host || data.scope.server}<div class="scope" role="status"><span>Showing {data.hostName ? `host: ${data.hostName}` : ''}{data.scope.host && data.scope.server ? ' · ' : ''}{data.scope.server ? `server: ${data.scope.server}` : ''}</span><a href={`/hosts/operations?filter=${history.filter}`}>Clear scope</a></div>{/if}
|
||||
<div class="history-filters">
|
||||
<nav class="filter-links" aria-label="Operation history filters">
|
||||
@@ -82,7 +88,7 @@
|
||||
</div>
|
||||
{#if refreshError}<p class="ui-error" role="alert">{refreshError}</p>{/if}
|
||||
<section class="panel" aria-label="Host operation history">
|
||||
{#if !history.operations.length}<p class="empty" role="status">{selectedFilter.empty}</p>
|
||||
{#if !history.operations.length}<p class="empty" role="status">{data.scope.operation ? 'This operation is unavailable in the selected history. It may have expired or be excluded by the current filters.' : selectedFilter.empty}</p>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Operations" tabindex="0">
|
||||
@@ -118,6 +124,10 @@
|
||||
|
||||
<style>
|
||||
.scope { display:flex; flex-wrap:wrap; gap:16px; color:var(--text-secondary); font-size:var(--text-sm); }
|
||||
.selected-operation { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; padding:16px; border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); font-size:var(--text-sm); }
|
||||
.selected-operation > div { min-width:0; }
|
||||
.selected-operation code { display:block; margin-top:4px; overflow-wrap:anywhere; color:var(--text-secondary); font-size:var(--text-xs); }
|
||||
.selected-operation a { color:var(--accent-primary-hover); }
|
||||
.page { display: grid; gap: 24px; min-width: 0; }
|
||||
.history-filters { min-width: 0; }
|
||||
.history-filters p { margin: 10px 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import ActivityLog from '$ui/components/ActivityLog.svelte';
|
||||
import BackLink from '$ui/components/BackLink.svelte';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { tick, untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { MaintenanceOverview, MaintenancePolicy } from '$lib/maintenance.js';
|
||||
@@ -38,6 +39,15 @@
|
||||
let scheduleLoaded = false;
|
||||
let displayedZone = $state('');
|
||||
let request = 0;
|
||||
const focusedJob = $derived(/^[1-9]\d*$/.test($page.url.searchParams.get('job') ?? '') ? $page.url.searchParams.get('job') : null);
|
||||
let scrolledJob: string | null = null;
|
||||
$effect(() => {
|
||||
if (!focusedJob) { scrolledJob = null; return; }
|
||||
if (data?.runtime.jobs.some(job => String(job.id) === focusedJob) && scrolledJob !== focusedJob) {
|
||||
scrolledJob = focusedJob;
|
||||
void tick().then(() => document.getElementById(`job-${focusedJob}`)?.scrollIntoView({ block: 'start' }));
|
||||
}
|
||||
});
|
||||
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 }[] = [
|
||||
@@ -71,7 +81,7 @@
|
||||
const isLatest = (item: (typeof activities)[number]) => !item.job || !data?.runtime.jobs.some(j => j.server === item.server && j.kind === item.job!.kind && j.id > item.job!.id);
|
||||
const attention = $derived(activities.filter(a => !a.server_deleted_at && a.state === 'attention' && isLatest(a)).length);
|
||||
const waiting = $derived(activities.filter(a => !a.server_deleted_at && a.state === 'active').length);
|
||||
const matching = $derived(activities.filter(a =>
|
||||
const matching = $derived(activities.filter(a => focusedJob ? a.key === `job-${focusedJob}` :
|
||||
(kind === 'all' || a.kind === kind) &&
|
||||
(!server || `${a.server} ${serverTitle(a.server)}`.toLowerCase().includes(server.toLowerCase())) &&
|
||||
(view === 'overview' ? !a.server_deleted_at && a.state !== 'done' && isLatest(a) : status === 'all' || a.state === status)
|
||||
@@ -103,12 +113,12 @@
|
||||
checkedAt = Date.now();
|
||||
if (!scheduleLoaded) { scheduleEnabled = body.runtime.schedule.enabled; scheduleLoaded = true; }
|
||||
}
|
||||
async function load() {
|
||||
async function load(job = focusedJob) {
|
||||
if (busy || refreshing) return;
|
||||
refreshing = true;
|
||||
const current = ++request;
|
||||
try {
|
||||
const response = await fetch('/api/maintenance');
|
||||
const response = await fetch(`/api/maintenance${job ? `?job=${encodeURIComponent(job)}` : ''}`);
|
||||
if (!response.ok) throw new Error('Could not refresh maintenance.');
|
||||
const body = await response.json();
|
||||
if (current === request) { receive(body); error = ''; }
|
||||
@@ -126,7 +136,7 @@
|
||||
} catch (cause) { error = (cause as Error).message; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
onMount(() => pollVisible(load, 5000));
|
||||
$effect(() => { const job = focusedJob; return untrack(() => pollVisible(() => load(job), 5000)); });
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Maintenance | Electric Cherry</title></svelte:head>
|
||||
@@ -137,7 +147,7 @@
|
||||
{#if data}<Button variant="ghost" disabled={busy} onclick={() => act({ action: 'pause', paused: !data!.policies.paused })}>{data.policies.paused ? 'Resume maintenance' : 'Pause all maintenance'}</Button>{/if}
|
||||
{/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}<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 server operations continue.</div>{/if}
|
||||
@@ -167,6 +177,9 @@
|
||||
{#if data.runtime.schedule.nextAt}<p>Next run: {$timeFormat.dateTime(data.runtime.schedule.nextAt)}{data.policies.paused ? ' · Held while maintenance is paused' : ''}</p>{/if}
|
||||
<p class="muted">Disabling the schedule stops future runs. Existing requests remain in Activity, where unsubmitted restarts can be cancelled.</p>
|
||||
</section>
|
||||
{:else if view === 'activity'}
|
||||
<ActivityLog source="maintenance" />
|
||||
<p class="muted">Use <a href="/maintenance?view=overview#activity">active work and attention</a> to cancel waiting maintenance or retry a failed verification after inspection.</p>
|
||||
{:else}
|
||||
{#if view === 'overview'}
|
||||
<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>
|
||||
@@ -223,7 +236,8 @@
|
||||
</section>
|
||||
{/if}
|
||||
<section id="activity">
|
||||
<h2>{view === 'overview' ? 'Active work & attention' : 'Maintenance activity'}</h2>
|
||||
<h2>{focusedJob ? `Maintenance request #${focusedJob}` : view === 'overview' ? 'Active work & attention' : 'Maintenance activity'}</h2>
|
||||
{#if focusedJob}<p class="muted">Selected retained request. <a href="/maintenance?view=overview#activity">Show active work and attention</a></p>{/if}
|
||||
<div class="activity">{#each matching.slice(0, limit) as item (item.key)}
|
||||
<article class="card" id={item.key}>
|
||||
<div class="row-heading"><h3>{item.title} · <HistoricalServerIdentity name={item.server} deletedAt={item.server_deleted_at} /></h3><Badge variant={item.state === 'attention' ? 'danger' : item.state === 'active' ? 'info' : 'neutral'}>{item.label}</Badge></div>
|
||||
@@ -234,7 +248,7 @@
|
||||
{#if item.job?.state === 'failed' && !item.server_deleted_at && item.job.operation_state === 'done'}<Button size="sm" disabled={busy || data.policies.paused} onclick={() => act({ action: 'retry', id: item.job!.id })}>Retry after inspection</Button>{/if}
|
||||
</div>
|
||||
</article>
|
||||
{:else}<EmptyState title={view === 'overview' ? 'No active maintenance in this view' : 'No matching activity'} description="Findings, scheduled restarts, and update verification appear here." />{/each}</div>
|
||||
{:else}<EmptyState title={focusedJob ? 'Maintenance request unavailable' : view === 'overview' ? 'No active maintenance in this view' : 'No matching activity'} description={focusedJob ? 'This request may have expired under the history retention settings.' : 'Findings, scheduled restarts, and update verification appear here.'} />{/each}</div>
|
||||
<ListFooter total={matching.length} bind:limit onchange={() => saveContext(false)} label="activities" />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export function load() { redirect(308, '/provisioning'); }
|
||||
export function load() { redirect(308, '/activity'); }
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
|
||||
<div class="page">
|
||||
<SectionHeader title={deployment ? `${actionLabels[deployment.action]} · ${serverTitle(deployment.server)}` : 'Provisioning operation'} subtitle={deployment?.server}>
|
||||
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Provisioning', href: '/provisioning' }, { label: 'Operation' }]} />{/snippet}
|
||||
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Activity', href: '/activity' }, { label: 'Operation' }]} />{/snippet}
|
||||
{#snippet actions()}{#if serverHref}<Button href={serverHref}>View server</Button>{/if}{/snippet}
|
||||
</SectionHeader>
|
||||
<div class="identity-row">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { instanceActivity } from '$ui/server-activity.js';
|
||||
import ActivityLog from '$ui/components/ActivityLog.svelte';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { freshTelemetry } from '$lib/server-observation.js';
|
||||
import { freshStatusTimestamp } from '$lib/server-freshness.js';
|
||||
@@ -12,7 +12,6 @@
|
||||
import MatchHistory from '$ui/components/MatchHistory.svelte';
|
||||
import TelemetryDiagnostics from '$ui/components/TelemetryDiagnostics.svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { fetchCampaigns } from '$ui/campaigns.js';
|
||||
import { createFleetStore } from '$ui/stores/fleet.js';
|
||||
import { freshServer, fleetIdentity } from '$lib/fleet-overview.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
@@ -78,10 +77,6 @@
|
||||
artifactId?: number | null; version?: number | null; releaseVersion?: string | null;
|
||||
sha256: string; size: number; updatedAt: string; source: string | null;
|
||||
}
|
||||
interface DeploymentRow {
|
||||
id: string; action: string; started_at: string; finished_at: string | null;
|
||||
servers: Array<{ server: string; restart_state: string; players: number | null; error: string | null; files_changed: number }>;
|
||||
}
|
||||
|
||||
let name = $derived($page.params.name ?? '');
|
||||
const detail = $derived(createFleetStore(name));
|
||||
@@ -90,12 +85,11 @@
|
||||
detail.hydrateFleet(data.serverSnapshot);
|
||||
return detail.startFleetMonitor();
|
||||
});
|
||||
const snapshot = $derived($fleet.receivedAt ? $fleet : data.serverSnapshot);
|
||||
const visibleFleet = $derived($fleet.receivedAt ? $fleetServers : data.serverSnapshot.servers);
|
||||
const serverContext = $derived(visibleFleet.find(s => s.name === name) ?? null);
|
||||
let server = $derived((serverContext as Server | null));
|
||||
const health = $derived(serverContext ? serverHealth(serverContext, $uiNow) : null);
|
||||
const canOperate = $derived($page.data.user?.role === 'admin' || $page.data.user?.role === 'operator');
|
||||
const canOperate = $derived($page.data?.user?.role === 'admin' || $page.data?.user?.role === 'operator');
|
||||
const availableActions = $derived(serverContext ? applicableRuntimeActions(serverContext, $uiNow) : []);
|
||||
const primaryAction = $derived(availableActions.includes('restart') ? 'restart' : availableActions.includes('start') ? 'start' : null);
|
||||
function openAction(action: RuntimeAction) { if (canOperate && availableActions.includes(action)) { lifecycleAction = action; lifecycleOpen = true; } }
|
||||
@@ -112,7 +106,6 @@
|
||||
});
|
||||
const native = $derived(!!server?.native_instance_id);
|
||||
let telemetryOpen = $state(false);
|
||||
let history = $state<DeploymentRow[]>([]);
|
||||
let inventory = $state<InventoryEntry[]>([]);
|
||||
let rconProbe = $state<RconProbeResult | null>(null);
|
||||
let probingRcon = $state(false);
|
||||
@@ -137,29 +130,18 @@
|
||||
}
|
||||
let tabLoading = $state(false);
|
||||
let tabError = $state('');
|
||||
let historyLimit = $state(25);
|
||||
let historyTotal = $state(0);
|
||||
let inventoryLimit = $state(25);
|
||||
const operations = $derived(snapshot.operations.filter(op => op.server === name));
|
||||
|
||||
const activity = $derived(instanceActivity(name, operations, history));
|
||||
|
||||
let tabSequence = 0;
|
||||
async function loadTab(currentName: string, tab: string, signal?: AbortSignal) {
|
||||
if (tab !== 'history' && tab !== 'configuration') return;
|
||||
if (tab !== 'configuration') return;
|
||||
const sequence = ++tabSequence;
|
||||
try {
|
||||
if (tab === 'history') {
|
||||
const body = await fetchCampaigns<DeploymentRow>({ server: currentName }, historyLimit, signal);
|
||||
if (sequence !== tabSequence || name !== currentName || activeSection !== tab || signal?.aborted) return;
|
||||
history = body.deployments; historyTotal = body.total;
|
||||
} else {
|
||||
const response = await fetch('/api/servers/' + encodeURIComponent(currentName) + '/scripts', { signal });
|
||||
if (!response.ok) throw new Error('Could not refresh inventory.');
|
||||
const body = await response.json();
|
||||
if (sequence !== tabSequence || name !== currentName || activeSection !== tab || signal?.aborted) return;
|
||||
inventory = body.scripts;
|
||||
}
|
||||
const response = await fetch('/api/servers/' + encodeURIComponent(currentName) + '/scripts', { signal });
|
||||
if (!response.ok) throw new Error('Could not refresh inventory.');
|
||||
const body = await response.json();
|
||||
if (sequence !== tabSequence || name !== currentName || activeSection !== tab || signal?.aborted) return;
|
||||
inventory = body.scripts;
|
||||
tabError = '';
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -170,8 +152,9 @@
|
||||
$effect(() => {
|
||||
const currentName = name;
|
||||
const tab = activeSection;
|
||||
tabError = ''; history = []; inventory = []; historyLimit = 25; inventoryLimit = 25;
|
||||
tabLoading = tab === 'history' || tab === 'configuration';
|
||||
tabError = ''; inventory = []; inventoryLimit = 25;
|
||||
tabLoading = tab === 'configuration';
|
||||
if (tab !== 'configuration') return;
|
||||
return pollVisible(signal => loadTab(currentName, tab, signal), 10000);
|
||||
});
|
||||
$effect(() => { name; rconProbe = null; });
|
||||
@@ -382,15 +365,8 @@
|
||||
{:else if activeSection === 'matches'}
|
||||
{#key name}<MatchHistory server={name} />{/key}
|
||||
{:else if activeSection === 'history'}
|
||||
<div class="history-links"><a href={contextHref(`/provisioning?view=history&server=${encodeURIComponent(name)}`, $page.url)}>Provisioning history</a><a href={contextHref(`/hosts/operations?server=${encodeURIComponent(name)}`, $page.url)}>Host operation history</a></div>
|
||||
<Card>
|
||||
<div class="panel-head"><h2>Activity</h2></div><p class="sub">Recent operations and campaign history</p>
|
||||
{#if tabLoading}<p class="sub">Loading campaign history…</p>{/if}
|
||||
{#if activity.length}<div class="table-scroll"><table class="detail-table"><thead><tr><th>Time</th><th>Activity</th><th>Result</th><th>Source</th></tr></thead><tbody>
|
||||
{#each activity as item (item.key)}<tr><td><time datetime={item.at} title={$timeFormat.dateTime(item.at)}>{ago(item.at, $uiNow)}</time></td><td>{item.label}{#if item.detail}<div class="ui-error">{item.detail}</div>{/if}</td><td><StatusBadge state={item.state} /></td><td><a href={contextHref(item.href, $page.url)}>{item.source}</a></td></tr>{/each}
|
||||
</tbody></table></div>{:else if !tabLoading && !tabError}<EmptyState title="No activity recorded" />{/if}
|
||||
</Card>
|
||||
<ListFooter total={historyTotal} bind:limit={historyLimit} onchange={() => loadTab(name, 'history')} label="campaigns" />
|
||||
<div class="history-links"><a href={contextHref(`/activity?server=${encodeURIComponent(name)}`, $page.url)}>Open in Activity</a></div>
|
||||
{#key name}<ActivityLog server={name} />{/key}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import { parseActivityQuery } from '../../src/lib/activity.js';
|
||||
import type { ActivityQuery } from '../../src/lib/activity.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
|
||||
afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); });
|
||||
const server = 't5_zombie_theater_1';
|
||||
const at = '2026-10-07T12:00:00.000Z';
|
||||
|
||||
async function setup() {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { createHost } = await import('../../src/lib/server/hosts/index.js');
|
||||
const { activityHistory } = await import('../../src/lib/server/activity.js');
|
||||
const host = createHost({ name: 'Dallas host' });
|
||||
const other = createHost({ name: 'Denver host' });
|
||||
const handle = db();
|
||||
const insert = handle.prepare(`INSERT INTO server_deployments
|
||||
(id,server,action,state,stage,started_at,triggered_by,server_deleted_at,observation_json,error)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`);
|
||||
function operation(id: string, options: { state?: string; action?: string; server?: string; source?: string; at?: string; deleted?: string } = {}) {
|
||||
insert.run(id, options.server ?? server, options.action ?? 'restart', options.state ?? 'done', 'Private stage token=secret-stage',
|
||||
options.at ?? at, options.source ?? 'ui', options.deleted ?? null, '{"token":"secret-observation"}', 'secret-error');
|
||||
}
|
||||
function remote(id: string, options: { kind?: string; state?: string; key?: string; routine?: number; success?: number; expired?: string; host?: string; instance?: string } = {}) {
|
||||
handle.prepare(`INSERT INTO ec_host_operations (id,host_id,kind,instance_id,idempotency_key,payload,result,state,created_at,updated_at,
|
||||
routine,observation_success,server_name,history_expired_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
.run(id, options.host ?? host.id, options.kind ?? 'instance.restart', options.instance ?? null, options.key ?? null,
|
||||
'{"token":"secret-payload"}', '{"token":"secret-result"}', options.state ?? 'completed', at, at,
|
||||
options.routine ?? 0, options.success ?? 0, server, options.expired ?? null);
|
||||
}
|
||||
function link(operationId: string, kind: string, id: string) {
|
||||
handle.prepare('INSERT INTO provisioning_sources(operation_id,kind,source_id,server) VALUES(?,?,?,?)').run(operationId, kind, id, server);
|
||||
}
|
||||
function maintenance(id: number, state = 'done', operationId: string | null = null) {
|
||||
handle.prepare(`INSERT INTO maintenance_jobs(id,server,state,target_revision,requested_at,kind,origin,provisioning_id,detail)
|
||||
VALUES(?,?,?,123,?,'update','manual',?,'secret-maintenance-detail')`).run(id, server, state, at, operationId);
|
||||
}
|
||||
function incident(id: string, kind = 'health', state = 'resolved') {
|
||||
handle.prepare(`INSERT INTO watchdog_incidents(id,server,kind,state,summary,details,first_seen_at,last_seen_at)
|
||||
VALUES(?,?,?,?,'secret-summary','{"credential":"secret-details"}',?,?)`).run(id, server, kind, state, at, at);
|
||||
}
|
||||
function instance(id: string, hostId = host.id) {
|
||||
handle.prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at)
|
||||
VALUES(?,?,?,'library','image',28960,?)`).run(id, server, hostId, at);
|
||||
}
|
||||
return { handle, activityHistory, operation, remote, link, maintenance, incident, instance, host, other };
|
||||
}
|
||||
|
||||
describe('Activity query parameters', () => {
|
||||
it('keeps exact identities and valid filters while bounding pagination and ignoring invalid enum values', () => {
|
||||
expect(parseActivityQuery(new URLSearchParams('server=t5_zombie_theater_1&host=a&type=restart&source=maintenance&state=failed&days=7&q=Kino&limit=999&offset=20')))
|
||||
.toMatchObject({ server, host: 'a', type: 'restart', source: 'maintenance', state: 'failed', days: 7, q: 'Kino', limit: 100, offset: 20 });
|
||||
expect(parseActivityQuery(new URLSearchParams('type=sql&state=bad&source=bad&days=NaN&limit=-5&offset=Infinity')))
|
||||
.toEqual({ type: undefined, state: undefined, source: undefined, limit: 25, offset: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('unified retained Activity', () => {
|
||||
it('paginates the full union with exact totals, stable ties and removed-server history', async () => {
|
||||
const api = await setup();
|
||||
api.handle.transaction(() => {
|
||||
for (let i = 0; i < 130; i++) api.operation(`life-${String(i).padStart(3, '0')}`);
|
||||
for (let i = 0; i < 33; i++) api.remote(`remote-${i}`);
|
||||
api.operation('removed', { deleted: at, at: '2025-01-01T00:00:00.000Z' });
|
||||
api.incident('finding'); api.maintenance(1);
|
||||
})();
|
||||
const all = [];
|
||||
for (let offset = 0; offset < 166; offset += 25) {
|
||||
const page = api.activityHistory({ offset });
|
||||
expect(page.total).toBe(166);
|
||||
expect(page.rows.length).toBeLessThanOrEqual(25);
|
||||
all.push(...page.rows);
|
||||
}
|
||||
expect(new Set(all.map(row => row.id)).size).toBe(166);
|
||||
expect(all.at(-1)).toMatchObject({ id: 'provisioning:removed', serverDeletedAt: at });
|
||||
expect(api.activityHistory({ offset: 99_999 }).offset).toBe(150);
|
||||
});
|
||||
|
||||
it('filters before pagination by exact server, host, state, type, days and friendly search', async () => {
|
||||
const api = await setup();
|
||||
for (let i = 0; i < 40; i++) api.operation(`other-${i}`, { server: 't5_zombie_moon_1' });
|
||||
api.operation('match', { state: 'failed' });
|
||||
api.operation('old', { state: 'failed', at: '2025-01-01T00:00:00.000Z' });
|
||||
api.instance('instance');
|
||||
api.handle.prepare('INSERT INTO native_provisioning(deployment_id,instance_id,generation) VALUES(?,?,1)').run('match', 'instance');
|
||||
const filters: ActivityQuery = { server, host: api.host.id, state: 'failed', type: 'restart', q: 'Kino', days: 7, limit: 1 };
|
||||
const page = api.activityHistory(filters, Date.parse(at));
|
||||
expect(page.total).toBe(1);
|
||||
expect(page.rows[0]).toMatchObject({ id: 'provisioning:match', hostId: api.host.id, hostName: 'Dallas host' });
|
||||
expect(api.activityHistory({ ...filters, server: 'Kino' }, Date.parse(at)).total).toBe(0);
|
||||
expect(api.activityHistory({ ...filters, host: api.other.id }, Date.parse(at)).total).toBe(0);
|
||||
expect(api.activityHistory({ q: "' OR 1=1 --" }).total).toBe(0);
|
||||
expect(api.activityHistory({ q: 'Dallas' }).total).toBe(1);
|
||||
});
|
||||
|
||||
it('compares instants across SQLite and ISO timestamp formats for date ranges and ordering', async () => {
|
||||
const api = await setup();
|
||||
api.operation('iso-noon', { at: '2026-10-07T12:00:00.000Z' });
|
||||
api.operation('sql-afternoon', { at: '2026-10-07 13:00:00' });
|
||||
api.operation('sql-yesterday', { at: '2026-10-06 14:00:00' });
|
||||
api.operation('too-old', { at: '2026-10-06T11:00:00.000Z' });
|
||||
const page = api.activityHistory({ days: 1 }, Date.parse(at));
|
||||
expect(page.rows.map(row => row.id)).toEqual(['provisioning:sql-afternoon', 'provisioning:iso-noon', 'provisioning:sql-yesterday']);
|
||||
expect(page.total).toBe(3);
|
||||
});
|
||||
|
||||
it('merges maintenance verification into its lifecycle row and retains every trigger membership', async () => {
|
||||
const api = await setup();
|
||||
api.operation('shared');
|
||||
api.maintenance(1, 'failed', 'shared');
|
||||
api.link('shared', 'maintenance', '1');
|
||||
api.link('shared', 'campaign', 'expired-campaign');
|
||||
api.link('shared', 'beta', '99');
|
||||
const row = api.activityHistory().rows[0];
|
||||
expect(api.activityHistory().total).toBe(1);
|
||||
expect(row).toMatchObject({ id: 'provisioning:shared', type: 'update', state: 'failed', source: 'maintenance',
|
||||
sources: ['maintenance', 'campaign', 'beta'], href: '/provisioning/shared', detail: 'The restart completed, but maintenance verification failed.' });
|
||||
for (const source of ['maintenance', 'campaign', 'beta'] as const) expect(api.activityHistory({ source }).total).toBe(1);
|
||||
api.handle.prepare("UPDATE maintenance_jobs SET state='restarting' WHERE id=1").run();
|
||||
expect(api.activityHistory().rows[0]).toMatchObject({ state: 'running', rawState: 'verifying' });
|
||||
api.handle.prepare("UPDATE maintenance_jobs SET state='done' WHERE id=1").run();
|
||||
expect(api.activityHistory().rows[0].state).toBe('succeeded');
|
||||
});
|
||||
|
||||
it('includes recovery in the maintenance umbrella and keeps findings distinct from recovery actions', async () => {
|
||||
const api = await setup();
|
||||
api.operation('recovery', { source: 'watchdog:finding', action: 'rebuild' });
|
||||
api.incident('finding', 'drift');
|
||||
api.maintenance(1);
|
||||
expect(api.activityHistory({ source: 'maintenance' }).total).toBe(3);
|
||||
const recovery = api.activityHistory({ source: 'recovery' });
|
||||
expect(recovery.total).toBe(2);
|
||||
expect(recovery.rows.map(row => row.type).sort()).toEqual(['drift', 'rebuild']);
|
||||
expect(api.activityHistory({ source: 'provisioning' }).total).toBe(0);
|
||||
});
|
||||
|
||||
it('merges legacy source links without applying a retried verification result to the earlier operation', async () => {
|
||||
const api = await setup();
|
||||
api.operation('old', { source: 'maintenance:1', at: '2026-10-06T12:00:00.000Z' });
|
||||
api.operation('new', { source: 'maintenance:1' });
|
||||
api.maintenance(1, 'failed');
|
||||
const rows = api.activityHistory().rows;
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows.find(row => row.id === 'provisioning:old')?.state).toBe('succeeded');
|
||||
expect(rows.find(row => row.id === 'provisioning:new')?.state).toBe('failed');
|
||||
});
|
||||
|
||||
it('deduplicates current command pointers and historical idempotency attempts without dropping independent work', async () => {
|
||||
const api = await setup();
|
||||
api.operation('operation'); api.instance('instance');
|
||||
api.remote('pointed'); api.remote('staged'); api.remote('migration');
|
||||
api.handle.prepare(`INSERT INTO native_provisioning(deployment_id,instance_id,generation,operation_id,stage_operation_id)
|
||||
VALUES('operation','instance',1,'pointed','staged')`).run();
|
||||
api.handle.prepare(`INSERT INTO ec_host_migrations(deployment_id,source_host_id,instance_id,remove_operation_id)
|
||||
VALUES('operation',?,'instance','migration')`).run(api.host.id);
|
||||
for (const key of ['operation', 'operation:stage', 'operation:migration-create:1', 'operation:migrate-remove:2']) api.remote(`linked-${key}`, { key });
|
||||
api.remote('independent');
|
||||
api.remote('not-a-retry', { key: 'operation:migration-create:not-a-number' });
|
||||
api.remote('source-retained-away', { key: 'expired-operation' });
|
||||
expect(api.activityHistory().rows.map(row => row.id).sort()).toEqual([
|
||||
'host:independent', 'host:not-a-retry', 'host:source-retained-away', 'provisioning:operation'
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps failed host observations, hides successful routine polls and expired records, and never projects private data', async () => {
|
||||
const api = await setup();
|
||||
api.operation('operation'); api.incident('finding'); api.maintenance(1);
|
||||
api.remote('routine', { kind: 'instance.inspect', routine: 1, success: 1 });
|
||||
api.remote('bad-check', { kind: 'instance.inspect', routine: 1 });
|
||||
api.remote('failed-check', { kind: 'instance.inspect', routine: 1, state: 'failed' });
|
||||
api.remote('expired', { expired: at });
|
||||
const result = api.activityHistory();
|
||||
expect(result.rows.filter(row => row.source === 'host').map(row => row.id).sort()).toEqual(['host:bad-check', 'host:failed-check']);
|
||||
expect(result.rows.find(row => row.id === 'host:bad-check')).toMatchObject({ state: 'failed', rawState: 'observation-failed', href: '/hosts/operations?operation=bad-check&filter=all' });
|
||||
expect(JSON.stringify(result)).not.toContain('secret-');
|
||||
expect(JSON.stringify(result)).not.toMatch(/payload|observation_json|result|credential/);
|
||||
});
|
||||
|
||||
it('retains file campaigns and beta tests as distinct work with friendly titles', async () => {
|
||||
const api = await setup();
|
||||
api.handle.exec(`INSERT INTO scripts(id,name,target_rel_path) VALUES(1,'Script','script.gsc');
|
||||
INSERT INTO script_versions(id,script_id,version,sha256,size) VALUES(1,1,1,'hash',0);
|
||||
INSERT INTO betas(id,script_id,script_version_id,trigger_kind,state,opened_at) VALUES(1,1,1,'ui','promoted','${at}');
|
||||
INSERT INTO beta_servers(beta_id,server,had_original,stage) VALUES(1,'${server}',0,'ready');
|
||||
INSERT INTO deployments(id,action,started_at) VALUES('campaign','promote','${at}');
|
||||
INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state,server_deleted_at) VALUES('campaign','${server}',1,'done','${at}');`);
|
||||
const rows = api.activityHistory().rows;
|
||||
expect(rows).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ source: 'campaign', title: 'Promote files', detail: '1 file changed.', state: 'succeeded', serverDeletedAt: at, href: '/campaigns/campaign' }),
|
||||
expect.objectContaining({ source: 'beta', title: 'Beta test #1', state: 'succeeded', href: '/betas/1' })
|
||||
]));
|
||||
api.handle.prepare("UPDATE betas SET state='running' WHERE id=1").run();
|
||||
for (const stage of ['preparing', 'staging', 'testing', 'observing']) {
|
||||
api.handle.prepare('UPDATE beta_servers SET stage=? WHERE beta_id=1').run(stage);
|
||||
expect(api.activityHistory({ source: 'beta' }).rows[0].state).toBe('running');
|
||||
}
|
||||
api.handle.prepare("UPDATE deployments SET triggered_by='watchdog' WHERE id='campaign'").run();
|
||||
expect(api.activityHistory({ source: 'maintenance' }).rows[0]).toMatchObject({ sources: ['recovery', 'campaign'] });
|
||||
expect(api.activityHistory({ source: 'recovery' }).total).toBe(1);
|
||||
expect(api.activityHistory({ source: 'campaign' }).total).toBe(1);
|
||||
});
|
||||
|
||||
it('uses the same query contract for API and page loaders with no-store API responses', async () => {
|
||||
const api = await setup();
|
||||
api.operation('a'); api.operation('b', { state: 'failed' });
|
||||
const url = new URL('http://localhost/activity?state=failed&limit=1');
|
||||
const { GET } = await import('../../src/routes/api/activity/+server.js');
|
||||
const { load } = await import('../../src/routes/activity/+page.server.js');
|
||||
const response = GET({ url });
|
||||
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||
expect(await response.json()).toEqual(JSON.parse(JSON.stringify(load({ url }).activity)));
|
||||
expect(load({ url }).activity.rows[0].id).toBe('provisioning:b');
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ describe('command palette destinations', () => {
|
||||
it('keeps administrator destinations and operations out of inappropriate roles', () => {
|
||||
const viewer = palettePageResults('viewer', '');
|
||||
expect(viewer.some(item => item.group === 'Actions' || item.href.startsWith('/settings') || item.href === '/server-keys')).toBe(false);
|
||||
expect(palettePageResults('operator', '').filter(item => item.group === 'Actions').map(item => item.href)).toEqual(['/?action=create']);
|
||||
expect(palettePageResults('operator', '').filter(item => item.group === 'Actions').map(item => item.href)).toEqual(['/?action=create', '/provisioning']);
|
||||
expect(palettePageResults('admin', 'enroll')).toContainEqual(expect.objectContaining({ href: '/hosts?action=add' }));
|
||||
expect(palettePageResults('admin', 'campaigns')).toContainEqual(expect.objectContaining({ label: 'Campaigns', href: '/campaigns' }));
|
||||
});
|
||||
|
||||
@@ -102,6 +102,27 @@ describe('host operation history', () => {
|
||||
expect(api.listHostOperationHistory(1, 'important', { host: api.second.id }).operations.map(operation => operation.id)).toEqual([other.id]);
|
||||
});
|
||||
|
||||
it('finds an exact old operation without walking pages and keeps all scope and retention filters', async () => {
|
||||
const api = await seedHistory(1003);
|
||||
const operation = api.ids[0];
|
||||
api.handle.prepare("UPDATE ec_host_operations SET server_name='server-one' WHERE id=?").run(operation);
|
||||
const prepare = vi.spyOn(api.handle, 'prepare');
|
||||
const history = api.listHostOperationHistory(999, 'all', { operation, host: api.first.id, server: 'server-one' });
|
||||
const exactCount = prepare.mock.calls.map(([sql]) => sql).find(sql => sql.startsWith('SELECT 1 AS total'))!;
|
||||
prepare.mockRestore();
|
||||
expect(history).toMatchObject({ page: 1, total: 1, totalPages: 1 });
|
||||
expect(history.operations.map(row => row.id)).toEqual([operation]);
|
||||
expect(JSON.stringify(history)).not.toContain('secret');
|
||||
const plan = api.handle.prepare(`EXPLAIN QUERY PLAN ${exactCount}`).all(api.first.id, 'server-one', operation) as { detail: string }[];
|
||||
expect(plan.some(row => row.detail.includes('USING INDEX sqlite_autoindex_ec_host_operations_1'))).toBe(true);
|
||||
expect(api.listHostOperationHistory(1, 'all', { operation, host: api.second.id }).total).toBe(0);
|
||||
expect(api.listHostOperationHistory(1, 'all', { operation, server: 'another-server' }).total).toBe(0);
|
||||
expect(api.listHostOperationHistory(1, 'routine', { operation }).total).toBe(0);
|
||||
expect(api.listHostOperationHistory(1, 'all', { operation: 'missing-operation' }).total).toBe(0);
|
||||
api.handle.prepare('UPDATE ec_host_operations SET history_expired_at=? WHERE id=?').run(new Date().toISOString(), operation);
|
||||
expect(api.listHostOperationHistory(1, 'all', { operation })).toMatchObject({ operations: [], page: 1, total: 0 });
|
||||
});
|
||||
|
||||
it('pages all hosts in stable newest-first order, retaining deleted-host records', async () => {
|
||||
const api = await seedHistory();
|
||||
const pages = [1, 2, 3].map(page => api.listHostOperationHistory(page, 'all'));
|
||||
@@ -309,6 +330,17 @@ describe('host operation history', () => {
|
||||
expect(await request('?filter=all')).toEqual(api.listHostOperationHistory(1, 'all'));
|
||||
});
|
||||
|
||||
it('serves an exact old operation through the API without exposing command data', async () => {
|
||||
const api = await seedHistory();
|
||||
const operation = api.ids[0];
|
||||
const { GET } = await import('../../src/routes/api/hosts/operations/+server.js');
|
||||
const response = GET({ request: new Request(`https://ec.test/api/hosts/operations?filter=all&operation=${operation}`) });
|
||||
const body = await response.json();
|
||||
expect(body).toMatchObject({ page: 1, total: 1, totalPages: 1 });
|
||||
expect(body.operations.map((row: { id: string }) => row.id)).toEqual([operation]);
|
||||
expect(JSON.stringify(body)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('rejects invalid API pages and keeps host management access restrictions', async () => {
|
||||
const { GET } = await import('../../src/routes/api/hosts/operations/+server.js');
|
||||
const url = 'https://ec.test/api/hosts/operations';
|
||||
@@ -318,6 +350,9 @@ describe('host operation history', () => {
|
||||
for (const filter of ['', 'unknown', 'ALL', 'important%27%20OR%201=1']) {
|
||||
expect(() => GET({ request: new Request(`${url}?filter=${filter}`) })).toThrow(expect.objectContaining({ status: 400 }));
|
||||
}
|
||||
for (const operation of ['', 'x'.repeat(513), 'operation%00bad', 'operation%0Abad']) {
|
||||
expect(() => GET({ request: new Request(`${url}?operation=${operation}`) })).toThrow(expect.objectContaining({ status: 400 }));
|
||||
}
|
||||
const blockedHeaders: HeadersInit[] = [
|
||||
{ origin: 'https://other.test' },
|
||||
{ 'sec-fetch-site': 'cross-site' },
|
||||
|
||||
@@ -30,6 +30,20 @@ describe('Maintenance API', () => {
|
||||
}
|
||||
expect(await GET().json()).toEqual(before);
|
||||
});
|
||||
it('opens one retained request outside the recent history window without changing normal history', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const insert = db().prepare("INSERT INTO maintenance_jobs(server,state,requested_at,kind,origin) VALUES(?,'done',?,'schedule','schedule')");
|
||||
db().transaction(() => { for (let i = 0; i < 105; i++) insert.run('t5_zombie_theater_1', new Date(Date.UTC(2026, 0, 1, 0, i)).toISOString()); })();
|
||||
const { GET } = await import('../../src/routes/api/maintenance/+server.js');
|
||||
const ordinary = await GET().json();
|
||||
expect(ordinary.runtime.jobs).toHaveLength(100);
|
||||
expect(ordinary.runtime.jobs.some((job: { id: number }) => job.id === 1)).toBe(false);
|
||||
const selected = await GET({ url: new URL('http://localhost/api/maintenance?job=1') }).json();
|
||||
expect(selected.runtime.jobs).toHaveLength(101);
|
||||
expect(selected.runtime.jobs.find((job: { id: number }) => job.id === 1)).toMatchObject({ state: 'done', kind: 'schedule' });
|
||||
for (const job of ['0', '-1', '', '1.5', 'bad', '99999999999999999999']) expect(GET({ url: new URL(`http://localhost/api/maintenance?job=${job}`) }).status).toBe(400);
|
||||
expect(await GET().json()).toEqual(ordinary);
|
||||
});
|
||||
it('keeps old Watchdog URLs working through query-preserving redirects', async () => {
|
||||
const index = await import('../../src/routes/watchdog/+page.server.js');
|
||||
const detail = await import('../../src/routes/watchdog/[id]/+page.server.js');
|
||||
|
||||
@@ -10,6 +10,13 @@ describe('navigation context', () => {
|
||||
expect(returnLabel('/collections?view=history&launches=30')).toBe('Back to launch history');
|
||||
});
|
||||
|
||||
it('retains Activity filters and a selected result when opening a detail', () => {
|
||||
const source = '/activity?source=maintenance&state=failed&offset=25#operation-12';
|
||||
const target = new URL(contextHref('/provisioning/12', source), 'http://localhost');
|
||||
expect(safeReturnHref(target.searchParams.get('returnTo'), '/')).toBe(source);
|
||||
expect(returnLabel(source)).toBe('Back to Activity');
|
||||
});
|
||||
|
||||
it('preserves the selected diagnostic tab on both sides of a contextual link', () => {
|
||||
const url = new URL(contextHref('/servers/t5_zombie_theater_1?tab=logs&log=console', '/maintenance/incident?returnTo=%2Fmaintenance%3Fview%3Dactivity'), 'http://localhost');
|
||||
expect(url.searchParams.get('tab')).toBe('logs');
|
||||
|
||||
@@ -6,8 +6,9 @@ describe('application navigation', () => {
|
||||
['/servers/t5_zombie_theater_1', 'fleet', '/'],
|
||||
['/collections/launches/42', 'fleet', '/collections'],
|
||||
['/hosts/42', 'fleet', '/hosts'],
|
||||
['/hosts/operations', 'activity', '/hosts/operations'],
|
||||
['/provisioning', 'activity', '/provisioning'],
|
||||
['/hosts/operations', 'activity', '/activity'],
|
||||
['/provisioning', 'activity', '/activity'],
|
||||
['/activity', 'activity', '/activity'],
|
||||
['/betas/new', 'releases', '/betas'],
|
||||
['/campaigns/42', 'releases', '/campaigns'],
|
||||
['/configs/42', 'releases', '/configs'],
|
||||
@@ -20,9 +21,9 @@ describe('application navigation', () => {
|
||||
});
|
||||
|
||||
it('keeps Operations reachable from Activity and redirects legacy overview links', async () => {
|
||||
expect(navigationGroups.find(group => group.id === 'activity')?.items[0].href).toBe('/provisioning');
|
||||
expect(navigationGroups.find(group => group.id === 'activity')?.items[0].href).toBe('/activity');
|
||||
const { load } = await import('../../src/routes/operations/+page.server.js');
|
||||
expect(load).toThrow(expect.objectContaining({ status: 308, location: '/provisioning' }));
|
||||
expect(load).toThrow(expect.objectContaining({ status: 308, location: '/activity' }));
|
||||
});
|
||||
|
||||
it('keeps administration and its shortcuts out of operator and viewer navigation', () => {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, afterEach, expect, it, vi } from 'vitest';
|
||||
import { render } from 'svelte/server';
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
import { page } from '$app/stores';
|
||||
import ActivityLog from '../../src/lib/ui/components/ActivityLog.svelte';
|
||||
import ActivityPage from '../../src/routes/activity/+page.svelte';
|
||||
import { activityApiHref, activityHistoryHref, activityQuery, activityTimeLabel } from '../../src/lib/ui/activity-history.js';
|
||||
import type { ActivityPage as ActivityReport, ActivityRow } from '../../src/lib/activity.js';
|
||||
|
||||
vi.mock('$app/stores', () => ({ page: writable({ url: new URL('http://localhost/activity') }) }));
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
vi.mock('$ui/stores/fleet.js', () => ({ fleet: writable({ servers: [], operations: [], receivedAt: 0 }) }));
|
||||
vi.mock('$ui/stores/servers.js', () => ({ uiNow: writable(Date.parse('2026-10-07T18:00:00Z')) }));
|
||||
|
||||
const row: ActivityRow = { id: 'operation-1', at: '2026-10-07T17:59:45Z', type: 'restart', source: 'maintenance', sources: ['maintenance', 'campaign'], state: 'succeeded', rawState: 'done', title: 'Restart server', detail: 'Verified game readiness.', server: 't6_zombie_town_1', serverDeletedAt: null, hostId: 'host-a', hostName: 'Dallas 1', href: '/provisioning/operation-1' };
|
||||
const report = (): ActivityReport => ({ rows: [{ ...row }], total: 52, limit: 25, offset: 0, query: { limit: 25, offset: 0 } });
|
||||
function scope(path = '/activity') { (page as unknown as Writable<{ url: URL }>).set({ url: new URL(`http://localhost${path}`) }); }
|
||||
beforeEach(() => { scope(); vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-07T18:00:00Z')); });
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('sends only supported normalized filters and enforces scoped identities', () => {
|
||||
const url = new URL('https://example.test/servers/town?tab=history&server=wrong&source=host&state=failed&offset=50&returnTo=%2F');
|
||||
const href = activityApiHref(url, { server: 't6_zombie_town_1', source: 'maintenance' });
|
||||
const params = new URL(href, url).searchParams;
|
||||
expect(params.get('server')).toBe('t6_zombie_town_1'); expect(params.get('source')).toBe('maintenance');
|
||||
expect(params.get('state')).toBe('failed'); expect(params.get('offset')).toBe('50'); expect(params.get('limit')).toBe('25');
|
||||
expect(params.has('tab')).toBe(false); expect(params.has('returnTo')).toBe(false);
|
||||
expect(activityQuery(new URL('https://example.test/activity?type=unknown&days=0')).type).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resets pagination when filtering and preserves the containing page, hash, and return context', () => {
|
||||
const current = new URL('https://example.test/servers/town?tab=history&offset=50&source=campaign&returnTo=%2F%3Ffilter%3Dattention#history');
|
||||
const filtered = new URL(activityHistoryHref(current, { state: 'failed', q: 'Dallas lobby' }), current);
|
||||
expect(filtered.searchParams.get('offset')).toBeNull(); expect(filtered.searchParams.get('tab')).toBe('history');
|
||||
expect(filtered.searchParams.get('source')).toBe('campaign'); expect(filtered.searchParams.get('returnTo')).toBe('/?filter=attention');
|
||||
expect(filtered.hash).toBe('#history'); expect(filtered.searchParams.get('q')).toBe('Dallas lobby');
|
||||
expect(new URL(activityHistoryHref(filtered, { offset: '25' }), current).searchParams.get('state')).toBe('failed');
|
||||
});
|
||||
|
||||
it('shows relative recent timestamps and preserves accurate older dates and invalid evidence', () => {
|
||||
const now = Date.parse('2026-10-07T18:00:00Z');
|
||||
expect(activityTimeLabel('2026-10-07 17:59:45', 'America/New_York', now)).toBe('15 s ago');
|
||||
expect(activityTimeLabel('2026-10-07T17:50:00Z', 'America/New_York', now)).toBe('10 min ago');
|
||||
expect(activityTimeLabel('2026-10-07T16:00:00Z', 'America/New_York', now)).toBe('2 h ago');
|
||||
expect(activityTimeLabel('2026-10-06T17:00:00Z', 'America/New_York', now)).toBe('Yesterday, 1:00 PM');
|
||||
expect(activityTimeLabel('not a date', 'UTC', now)).toBe('Time unavailable');
|
||||
expect(activityTimeLabel('2026-10-08T18:00:00Z', 'America/New_York', now)).toBe('Oct 8, 2:00 PM');
|
||||
});
|
||||
|
||||
it('renders one page heading and keeps management tools available only to operators', () => {
|
||||
const body = render(ActivityPage, { props: { data: { activity: report(), user: { role: 'admin' }, setupRequired: false } as any } }).body;
|
||||
expect(body.match(/<h1[ >]/g)).toHaveLength(1); expect(body).toContain('Manage servers'); expect(body).toContain('href="/provisioning"');
|
||||
const viewer = render(ActivityPage, { props: { data: { activity: report(), user: { role: 'viewer' }, setupRequired: false } as any } }).body;
|
||||
expect(viewer).not.toContain('Manage servers');
|
||||
});
|
||||
|
||||
it('renders named operation links, shared source memberships, friendly resources, and one freshness indicator', () => {
|
||||
scope('/activity?state=succeeded&offset=25');
|
||||
const data = report(); data.offset = 25;
|
||||
const body = render(ActivityLog, { props: { initial: data } }).body;
|
||||
expect(body).toContain('Town Survival'); expect(body).toContain('Dallas 1'); expect(body).toContain('Restart server · Town Survival · Lobby 1');
|
||||
expect(body).toContain('Maintenance & recovery · Campaigns'); expect(body).toContain('Completed'); expect(body).toContain('shape-circle');
|
||||
expect(body).toContain('15 s ago'); expect(body).toContain('datetime="2026-10-07T17:59:45Z"');
|
||||
expect(body).toContain('returnTo=%2Factivity'); expect(body).toContain('state%3Dsucceeded'); expect(body).toContain('offset%3D25');
|
||||
expect(body).toContain('26–26 of 52'); expect(body).toContain('offset=50');
|
||||
expect(body.match(/Updates every 10 seconds/g)).toHaveLength(1); expect(body).not.toContain('aria-live');
|
||||
});
|
||||
|
||||
it('preserves deleted server evidence without linking to a replacement instance', () => {
|
||||
const data = report(); data.rows = [{ ...row, serverDeletedAt: '2026-10-07T17:59:46Z' }];
|
||||
const body = render(ActivityLog, { props: { initial: data } }).body;
|
||||
expect(body).toContain('Town Survival'); expect(body).toContain('Deleted'); expect(body).not.toContain('href="/servers/');
|
||||
expect(body).toContain('/provisioning/operation-1?returnTo=');
|
||||
});
|
||||
|
||||
it('offers one mobile filter sheet with distinct IDs and hides fixed scope controls', () => {
|
||||
const body = render(ActivityLog, { props: { initial: report() } }).body;
|
||||
expect(body).toContain('Activity filters'); expect(body).toContain('id="activity-host-mobile"'); expect(body).toContain('id="activity-search-mobile"');
|
||||
const ids = [...body.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]); expect(new Set(ids).size).toBe(ids.length);
|
||||
scope('/maintenance?view=activity&state=failed');
|
||||
const fixed = render(ActivityLog, { props: { initial: report(), server: row.server!, source: 'maintenance' } }).body;
|
||||
expect(fixed).not.toContain('id="activity-server'); expect(fixed).not.toContain('id="activity-source');
|
||||
expect(fixed).toContain('view=activity'); expect(fixed).toContain('state=failed'); expect(fixed).toContain('Filters · 1');
|
||||
});
|
||||
|
||||
it('offers useful empty and out-of-range page recovery', () => {
|
||||
scope('/activity?q=missing&offset=100');
|
||||
const data = report(); data.rows = []; data.offset = 100;
|
||||
const body = render(ActivityLog, { props: { initial: data } }).body;
|
||||
expect(body).toContain('No matching activity'); expect(body).toContain('Clear filters'); expect(body).toContain('No activities on this page');
|
||||
expect(body).toContain('offset=75'); expect(body).toContain('Previous');
|
||||
});
|
||||
@@ -19,7 +19,7 @@ describe('sidebar navigation', () => {
|
||||
expect(body).toContain('Main navigation');
|
||||
expect(body).toMatch(/<a[^>]*href="\/campaigns"[^>]*aria-current="page"/);
|
||||
expect(body).toContain('href="/betas"');
|
||||
expect(body).toContain('href="/hosts/operations"');
|
||||
expect(body).toContain('href="/activity"');
|
||||
expect(body).toContain('href="/metrics"');
|
||||
expect(body).not.toContain('href="/settings"');
|
||||
expect(body).not.toContain('href="/server-keys"');
|
||||
|
||||
@@ -4,12 +4,10 @@ import { writable } from 'svelte/store';
|
||||
import { get } from 'svelte/store';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import type { GameTelemetry } from '../../src/lib/telemetry.js';
|
||||
import type { FleetOperation } from '../../src/lib/fleet-overview.js';
|
||||
import ServerPage from '../../src/routes/servers/[name]/+page.svelte';
|
||||
import PlayerScoreboard from '../../src/lib/ui/components/PlayerScoreboard.svelte';
|
||||
import ConfigInspector from '../../src/lib/ui/components/ConfigInspector.svelte';
|
||||
import { uiNow } from '../../src/lib/ui/stores/servers.js';
|
||||
import { instanceActivity } from '../../src/lib/ui/server-activity.js';
|
||||
import { gameEvent } from '../helpers/telemetry.js';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
@@ -57,18 +55,8 @@ it('omits the fixed instance selector while keeping promoted/live distinction',
|
||||
expect(body).toContain('Live files and active betas may differ');
|
||||
});
|
||||
|
||||
const op = (changes: Partial<FleetOperation> = {}): FleetOperation => ({ key: 'deployment:1', group: 'deployment:1', server: 'one', kind: 'deployment', label: 'Complete', activityLabel: 'Restart instance', resultState: 'cancelled', detail: 'Checking game status', href: '/provisioning/1', state: 'done', startedAt: '2026-10-06T12:00:00Z', ...changes });
|
||||
it('merges duplicate campaign records, retains provisioning and preserves the real outcome', () => {
|
||||
const rows = instanceActivity('one', [op(), op({ key: 'campaign:x:one', kind: 'campaign', href: '/campaigns/x' }), op({ key: 'other', server: 'two' })], [{
|
||||
id: 'x', action: 'promote', started_at: '2026-10-06T13:00:00Z', servers: [{ server: 'one', restart_state: 'failed', error: 'Restart failed', files_changed: 1 }]
|
||||
}]);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toMatchObject({ label: 'Promote files · 1 file', state: 'failed', detail: 'Restart failed' });
|
||||
expect(rows[1]).toMatchObject({ label: 'Restart instance', state: 'cancelled', href: '/provisioning/1' });
|
||||
});
|
||||
|
||||
function pageBody(tab: string) {
|
||||
(page as unknown as ReturnType<typeof writable>).set({ params: { name: 't5_zombie_theater_1' }, url: new URL(`http://localhost/servers/t5_zombie_theater_1?tab=${tab}&returnTo=%2F%3Ffilter%3Doccupied`) });
|
||||
(page as unknown as ReturnType<typeof writable>).set({ data: { user: { role: 'admin' } }, params: { name: 't5_zombie_theater_1' }, url: new URL(`http://localhost/servers/t5_zombie_theater_1?tab=${tab}&returnTo=%2F%3Ffilter%3Doccupied`) });
|
||||
const telemetry = report();
|
||||
const server = { name: 't5_zombie_theater_1', game: 't5', map: 'zombie_theater', last_map: 'zombie_theater', health: 'online', last_players: 1,
|
||||
last_seen_at: telemetry.receivedAt, last_error: null, held: [], telemetry, status_source: 'telemetry', host_id: 'host-1', native_instance_id: 'instance-1',
|
||||
@@ -79,7 +67,7 @@ function pageBody(tab: string) {
|
||||
}
|
||||
it('keeps match content out of the shared header and preserves the fleet return filter', () => {
|
||||
const body = pageBody('configuration');
|
||||
expect(body).toContain('Instance actions');
|
||||
expect(body).toContain('More actions');
|
||||
expect(body).toContain('href="/?filter=occupied"');
|
||||
expect(body).toContain('ec-1');
|
||||
expect(body).not.toContain('Active for');
|
||||
@@ -96,3 +84,10 @@ it('reports RCON failures independently of fresh telemetry and defers the reques
|
||||
expect(body).not.toContain('Telemetry API requests');
|
||||
expect(body).not.toContain('>Healthy<');
|
||||
});
|
||||
|
||||
it('uses the shared Activity view for server history', () => {
|
||||
const body = pageBody('history');
|
||||
expect(body).toContain('Open in Activity');
|
||||
expect(body).not.toContain('Loading campaign history');
|
||||
expect(body).not.toContain('>Provisioning history</a>');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user