Unify retained operation history in Activity

This commit is contained in:
2026-10-07 19:30:38 -04:00
parent c39563daef
commit 05bdeaf624
35 changed files with 1008 additions and 120 deletions
+87
View File
@@ -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);
}