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));
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user