Add filters and context to host operation history

This commit is contained in:
2026-10-05 13:23:11 -04:00
parent e6912ceb57
commit a6c40cde9b
16 changed files with 417 additions and 46 deletions
+24 -8
View File
@@ -4,11 +4,12 @@ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypt
import { isIP } from 'node:net';
import { db } from '../db/index.js';
import { ensureHostSchema } from './schema.js';
import { successfulObservation } from './observations.js';
import { successfulObservation, routineObservationSql, importantOperationSql, operationTargetSql, safeOperationTarget } from './observations.js';
import { currentOperationSource } from './operation-context.js';
import { fleetSettings } from '../settings.js';
import { recordInstanceResources, validateInstanceResources } from './calibration.js';
import { refreshHostAvailability, scheduleAvailabilityRefresh } from './availability.js';
import type { Host, HostDecommission, HostDeletion, HostInventory, HostMode, HostOperation, HostOperationHistoryPage, HostOperationState } from '../../hosts.js';
import type { Host, HostDecommission, HostDeletion, HostInventory, HostMode, HostOperation, HostOperationFilter, HostOperationHistoryPage, HostOperationState } from '../../hosts.js';
export const AGENT_PROTOCOL_VERSION = 1;
export const HEARTBEAT_MAX_AGE_MS = 90_000;
@@ -133,7 +134,8 @@ export function deleteHost(id: string): { deleted: boolean; operationId?: string
const operationId = randomUUID();
const token = 'ec_cleanup_' + randomBytes(32).toString('base64url');
const payload = { hostId: id, operationId, receiptToken: token, receiptUrl: `/api/agent/decommission/${operationId}` };
handle.prepare("INSERT INTO ec_host_operations(id,host_id,kind,payload,state,created_at,updated_at) VALUES(?,?,'host.decommission',?,'pending',?,?)").run(operationId, id, JSON.stringify(payload), timestamp, timestamp);
handle.prepare("INSERT INTO ec_host_operations(id,host_id,kind,payload,state,created_at,updated_at,source) VALUES(?,?,'host.decommission',?,'pending',?,?,?)")
.run(operationId, id, JSON.stringify(payload), timestamp, timestamp, (currentOperationSource() ?? 'Controller').slice(0, 200));
handle.prepare("INSERT INTO ec_host_operation_events(operation_id,state,created_at) VALUES(?,'pending',?)").run(operationId, timestamp);
handle.prepare(`INSERT INTO ec_host_decommissions(host_id,operation_id,token_hash,requested_at,updated_at) VALUES(?,?,?,?,?)
ON CONFLICT(host_id) DO UPDATE SET operation_id=excluded.operation_id,token_hash=excluded.token_hash,stage='queued',requested_at=excluded.requested_at,updated_at=excluded.updated_at,cleaned_at=NULL,error=NULL`)
@@ -359,19 +361,26 @@ export function listHostOperations(hostId: string, limit = 100, summary = false)
.all(hostId, Math.min(500, Math.max(1, limit))) as OperationRow[]).map(operationFromRow);
}
/** Browse retained history without loading command payloads or credential-bearing results. */
export function listHostOperationHistory(page = 1): HostOperationHistoryPage {
export function listHostOperationHistory(page = 1, filter: HostOperationFilter = 'important'): HostOperationHistoryPage {
const handle = database();
const pageSize = 25;
const where = filter === 'all' ? '' : `WHERE ${filter === 'routine' ? 'o.routine=1' : importantOperationSql}`;
return handle.transaction((): HostOperationHistoryPage => {
const { total } = handle.prepare('SELECT COUNT(*) AS total FROM ec_host_operations').get() as { total: number };
const { total } = handle.prepare(`SELECT COUNT(*) AS total FROM ec_host_operations o ${where}`).get() as { total: number };
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,
o.instance_id AS instanceId,o.generation,o.state,o.error,o.created_at AS createdAt,o.updated_at AS updatedAt
o.instance_id AS instanceId,o.generation,o.state,
CASE WHEN o.routine=1 AND o.state='completed' AND o.observation_success=0
THEN COALESCE(NULLIF(o.error,''),'Observation reported a problem') ELSE o.error END AS error,
o.created_at AS createdAt,o.updated_at AS updatedAt,o.routine,o.server_name AS serverName,o.target_path AS targetPath,o.source
FROM ec_host_operations o LEFT JOIN ec_hosts h ON h.id=o.host_id AND h.deleted_at IS NULL
${where}
ORDER BY o.created_at DESC,o.rowid DESC LIMIT ? OFFSET ?`)
.all(pageSize, (currentPage - 1) * pageSize) as HostOperationHistoryPage['operations'];
return { operations, page: currentPage, pageSize, total, totalPages };
return { operations: operations.map(operation => ({ ...operation, routine: !!operation.routine,
targetPath: safeOperationTarget(operation.kind, operation.targetPath) })), filter,
page: currentPage, pageSize, total, totalPages };
})();
}
@@ -428,8 +437,15 @@ function enqueueHostOperation(hostId: string, kind: string, payload: string | Bu
if (['instance.create','create'].includes(kind)) requireReadyHost(hostId);
if (host.mode === 'disabled' && kind !== 'instance.cancel-create') throw new Error('Host is disabled. Enable it before requesting operations.');
const id = options.id ?? randomUUID(); const timestamp = now();
database().prepare('INSERT INTO ec_host_operations(id,host_id,kind,instance_id,generation,idempotency_key,payload,created_at,updated_at) VALUES(?,?,?,?,?,?,CAST(? AS TEXT),?,?)').run(id, hostId, kind, options.instanceId ?? null, options.generation ?? null, options.idempotencyKey ?? null, routingPayload ?? payload, timestamp, timestamp);
const serverName = options.instanceId ? (database().prepare('SELECT server FROM native_instances WHERE id=?')
.get(options.instanceId) as { server: string } | undefined)?.server ?? null : null;
database().prepare(`INSERT INTO ec_host_operations(id,host_id,kind,instance_id,generation,idempotency_key,payload,created_at,updated_at,source,server_name)
VALUES(?,?,?,?,?,?,CAST(? AS TEXT),?,?,?,?)`)
.run(id, hostId, kind, options.instanceId ?? null, options.generation ?? null, options.idempotencyKey ?? null,
routingPayload ?? payload, timestamp, timestamp, (currentOperationSource() ?? 'Controller').slice(0, 200), serverName);
if (routingPayload !== undefined) database().prepare('INSERT INTO ec_host_operation_payloads(operation_id,payload) VALUES(?,CAST(? AS TEXT))').run(id, payload);
database().prepare(`UPDATE ec_host_operations AS o SET routine=CASE WHEN ${routineObservationSql} THEN 1 ELSE 0 END,
target_path=${operationTargetSql} WHERE o.id=?`).run(id);
database().prepare('INSERT INTO ec_host_operation_events(operation_id,state,created_at) VALUES(?,?,?)').run(id, 'pending', timestamp);
return getHostOperationStatus(id)!;
})();