Unify retained operation history in Activity
This commit is contained in:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user