diff --git a/docs/development/ui-patterns.md b/docs/development/ui-patterns.md index e0565e2..48f449c 100644 --- a/docs/development/ui-patterns.md +++ b/docs/development/ui-patterns.md @@ -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. diff --git a/docs/guides/maintenance.md b/docs/guides/maintenance.md index 2d4e3f2..61ed29d 100644 --- a/docs/guides/maintenance.md +++ b/docs/guides/maintenance.md @@ -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 diff --git a/docs/guides/provisioning.md b/docs/guides/provisioning.md index 4a84295..e56c7f0 100644 --- a/docs/guides/provisioning.md +++ b/docs/guides/provisioning.md @@ -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 diff --git a/src/lib/activity.ts b/src/lib/activity.ts new file mode 100644 index 0000000..fe26882 --- /dev/null +++ b/src/lib/activity.ts @@ -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(value: string | null, labels: Record): 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); +} diff --git a/src/lib/hosts.ts b/src/lib/hosts.ts index c4e0ef1..633826e 100644 --- a/src/lib/hosts.ts +++ b/src/lib/hosts.ts @@ -130,10 +130,10 @@ export interface HostOperationSummary extends Omit = { '': '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 = { '': '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'}`; } diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index a8e6573..c06e92b 100644 --- a/src/lib/navigation.ts +++ b/src/lib/navigation.ts @@ -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: [ diff --git a/src/lib/server/activity.ts b/src/lib/server/activity.ts new file mode 100644 index 0000000..790a22e --- /dev/null +++ b/src/lib/server/activity.ts @@ -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(); +const quote = (value: string) => `'${value.replaceAll("'", "''")}'`; +const hostTitles: Record = { + '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, 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 & { 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 = {}; + 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 } }; + })(); +} diff --git a/src/lib/server/hosts/index.ts b/src/lib/server/hosts/index.ts index ccdea53..610c0c2 100644 --- a/src/lib/server/hosts/index.ts +++ b/src/lib/server/hosts/index.ts @@ -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, diff --git a/src/lib/server/maintenance/index.ts b/src/lib/server/maintenance/index.ts index 3b7698c..fc755db 100644 --- a/src/lib/server/maintenance/index.ts +++ b/src/lib/server/maintenance/index.ts @@ -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 { // One failing observer must not prevent the other capabilities from reconciling. diff --git a/src/lib/server/maintenance/runtime.ts b/src/lib/server/maintenance/runtime.ts index 86d3b8c..4cc7959 100644 --- a/src/lib/server/maintenance/runtime.ts +++ b/src/lib/server/maintenance/runtime.ts @@ -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[] }; } diff --git a/src/lib/ui/activity-history.ts b/src/lib/ui/activity-history.ts new file mode 100644 index 0000000..ab6f40c --- /dev/null +++ b/src/lib/ui/activity-history.ts @@ -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>): 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)); +} diff --git a/src/lib/ui/command-palette.ts b/src/lib/ui/command-palette.ts index 7e6136a..289c729 100644 --- a/src/lib/ui/command-palette.ts +++ b/src/lib/ui/command-palette.ts @@ -17,7 +17,7 @@ function matches(query: string, ...values: Array) { } 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 => ({ diff --git a/src/lib/ui/components/ActivityLog.svelte b/src/lib/ui/components/ActivityLog.svelte new file mode 100644 index 0000000..9831d62 --- /dev/null +++ b/src/lib/ui/components/ActivityLog.svelte @@ -0,0 +1,183 @@ + + +
+

Activity history

+ {#snippet filterFields(suffix: string)} +
+ + + {#if source === undefined}{/if} + {#if server === undefined}{/if} + + + +
+ {/snippet} +
{@render filterFields('')}
+
{data?.total ?? '—'} {data?.total === 1 ? 'activity' : 'activities'}{source ? ` · ${activitySources[source]}` : ''}
+ + {@render filterFields('-mobile')} + {#snippet actions()}{/snippet} + +
Newest first · Times in {$timeZone}{#if filterCount}{/if}{#if !error}{/if}
+ {#if error}{/if} + {#if loading} + {:else if loadedUrl !== apiUrl && error} + {:else if data?.rows.length} +
    + {#each data.rows as row (row.id)} +
  1. +

    {row.title}

    {#if row.detail}

    {row.detail}

    {/if} +
    {#if row.server && server === undefined}{/if} + {#if row.hostName}{row.hostName}{/if} +
    +
    +
    + {(row.sources.length ? row.sources : [row.source]).map(item => activitySources[item]).join(' · ')} +
    +
  2. + {/each} +
+ {:else if !error} + {#snippet actions()}{#if filterCount}{/if}{/snippet} + {/if} + {#if data && !loading && loadedUrl === apiUrl && (data.total > 0 || data.offset > 0)} + + {/if} +
+ + diff --git a/src/lib/ui/server-activity.ts b/src/lib/ui/server-activity.ts deleted file mode 100644 index 51196a0..0000000 --- a/src/lib/ui/server-activity.ts +++ /dev/null @@ -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)); -} diff --git a/src/routes/activity/+page.server.ts b/src/routes/activity/+page.server.ts new file mode 100644 index 0000000..3bd71eb --- /dev/null +++ b/src/routes/activity/+page.server.ts @@ -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)) }; +} diff --git a/src/routes/activity/+page.svelte b/src/routes/activity/+page.svelte new file mode 100644 index 0000000..fdde290 --- /dev/null +++ b/src/routes/activity/+page.svelte @@ -0,0 +1,19 @@ + + +Activity · Electric Cherry + +
+ + {#snippet actions()}{#if data.user && data.user.role !== 'viewer'}{/if}{/snippet} + + +
+ + diff --git a/src/routes/api/activity/+server.ts b/src/routes/api/activity/+server.ts new file mode 100644 index 0000000..e337d6d --- /dev/null +++ b/src/routes/api/activity/+server.ts @@ -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' } }); +} diff --git a/src/routes/api/hosts/operations/+server.ts b/src/routes/api/hosts/operations/+server.ts index f11f362..777085f 100644 --- a/src/routes/api/hosts/operations/+server.ts +++ b/src/routes/api/hosts/operations/+server.ts @@ -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' } }); diff --git a/src/routes/api/maintenance/+server.ts b/src/routes/api/maintenance/+server.ts index 9676165..e05987c 100644 --- a/src/routes/api/maintenance/+server.ts +++ b/src/routes/api/maintenance/+server.ts @@ -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 }); diff --git a/src/routes/hosts/operations/+page.server.ts b/src/routes/hosts/operations/+page.server.ts index 75a7215..130f215 100644 --- a/src/routes/hosts/operations/+page.server.ts +++ b/src/routes/hosts/operations/+page.server.ts @@ -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)) { diff --git a/src/routes/hosts/operations/+page.svelte b/src/routes/hosts/operations/+page.svelte index ebdaad5..6f85d90 100644 --- a/src/routes/hosts/operations/+page.svelte +++ b/src/routes/hosts/operations/+page.svelte @@ -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 @@ {#snippet actions()}{/snippet} - {#if $page.url.searchParams.has('returnTo')}{/if} + {#if data.scope.operation || $page.url.searchParams.has('returnTo')}{/if} + {#if data.scope.operation}
Selected host operation{data.scope.operation}
View all operations
{/if} {#if data.scope.host || data.scope.server}
Showing {data.hostName ? `host: ${data.hostName}` : ''}{data.scope.host && data.scope.server ? ' · ' : ''}{data.scope.server ? `server: ${data.scope.server}` : ''}Clear scope
{/if}
{#if refreshError}{/if}
- {#if !history.operations.length}

{selectedFilter.empty}

+ {#if !history.operations.length}

{data.scope.operation ? 'This operation is unavailable in the selected history. It may have expired or be excluded by the current filters.' : selectedFilter.empty}

{:else}
@@ -118,6 +124,10 @@