feat(fleet): card-only UI with progressive load and restart ops
Drop the redundant advanced table so fleet stays resource-first cards. Keep multi-select batch service/system restart via prepare→execute, card restarts, overview system ops, and progressive fleet loading.
This commit is contained in:
@@ -18,22 +18,47 @@ export interface SecureOperationDescriptor {
|
||||
export interface SecureOperationRegistry {
|
||||
requireExecutableOperation(operationId: string): SecureOperationDescriptor;
|
||||
}
|
||||
const EXECUTABLE_OPERATIONS: Readonly<Record<string, SecureOperationDescriptor>> = Object.freeze({
|
||||
postNetworkRegisterAuto: Object.freeze({
|
||||
operationId: 'postNetworkRegisterAuto',
|
||||
title: 'Register Network Automatically',
|
||||
riskLevel: 'R2',
|
||||
method: 'POST',
|
||||
pathTemplate: '/api/network/register-auto',
|
||||
requestContentType: 'none',
|
||||
parameterSchemaId: 'simadmin.58e2204.postNetworkRegisterAuto.parameters.v1',
|
||||
}),
|
||||
postServiceRestart: Object.freeze({
|
||||
operationId: 'postServiceRestart',
|
||||
title: 'Restart Service',
|
||||
riskLevel: 'R3',
|
||||
method: 'POST',
|
||||
pathTemplate: '/api/service/restart',
|
||||
requestContentType: 'none',
|
||||
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
|
||||
}),
|
||||
postSystemReboot: Object.freeze({
|
||||
operationId: 'postSystemReboot',
|
||||
title: 'Reboot System',
|
||||
riskLevel: 'R3',
|
||||
method: 'POST',
|
||||
pathTemplate: '/api/system/reboot',
|
||||
requestContentType: 'application/json',
|
||||
parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1',
|
||||
}),
|
||||
});
|
||||
|
||||
export const secureOperationRegistry: SecureOperationRegistry = {
|
||||
requireExecutableOperation(operationId) {
|
||||
if (operationId !== 'postNetworkRegisterAuto') throw new Error('UNKNOWN_OPERATION');
|
||||
return {
|
||||
operationId: 'postNetworkRegisterAuto',
|
||||
title: 'Register Network Automatically',
|
||||
riskLevel: 'R2',
|
||||
method: 'POST',
|
||||
pathTemplate: '/api/network/register-auto',
|
||||
requestContentType: 'none',
|
||||
parameterSchemaId: 'simadmin.58e2204.postNetworkRegisterAuto.parameters.v1',
|
||||
};
|
||||
const descriptor = EXECUTABLE_OPERATIONS[operationId];
|
||||
if (!descriptor) throw new Error('UNKNOWN_OPERATION');
|
||||
return descriptor;
|
||||
},
|
||||
};
|
||||
|
||||
const ALLOWED_OPERATION = 'postNetworkRegisterAuto';
|
||||
const ZERO_BODY_OPERATIONS = new Set(['postNetworkRegisterAuto', 'postServiceRestart']);
|
||||
const SYSTEM_REBOOT_OPERATION = 'postSystemReboot';
|
||||
const SYSTEM_REBOOT_DELAY_SECONDS = 3;
|
||||
const ACTOR = 'loopback-control-plane';
|
||||
const TTL_MS = 5 * 60 * 1000;
|
||||
const EMPTY_DIGEST = createHash('sha256').update('').digest('hex');
|
||||
@@ -44,7 +69,8 @@ export interface SafeOperationTransportRequest {
|
||||
readonly path: string;
|
||||
readonly query: string;
|
||||
readonly contentType: string;
|
||||
readonly body: undefined;
|
||||
/** Serialized JSON body for audited JSON operations; undefined for zero-body POSTs. */
|
||||
readonly body: string | undefined;
|
||||
}
|
||||
export interface SafeOperationTransport {
|
||||
request(request: SafeOperationTransportRequest): Promise<{ readonly status: number }>;
|
||||
@@ -107,8 +133,8 @@ const equalDigest = (left: string, right: string): boolean => {
|
||||
};
|
||||
const validRevision = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
||||
const parameterDigest = (schema: string): string =>
|
||||
digest(JSON.stringify({ parameterSchemaId: schema, fields: [] }));
|
||||
const parameterDigest = (schema: string, fields: readonly unknown[] = []): string =>
|
||||
digest(JSON.stringify({ parameterSchemaId: schema, fields }));
|
||||
|
||||
export class SecureOperationExecution {
|
||||
private readonly clock: () => Date;
|
||||
@@ -131,11 +157,6 @@ export class SecureOperationExecution {
|
||||
typeof input.operationId !== 'string'
|
||||
)
|
||||
this.validation();
|
||||
if (input.operationId !== ALLOWED_OPERATION)
|
||||
throw new SecureOperationExecutionError(
|
||||
'OPERATION_NOT_ALLOWED',
|
||||
'Operation is not enabled for generic execution',
|
||||
);
|
||||
let descriptor: SecureOperationDescriptor;
|
||||
try {
|
||||
descriptor = this.options.registry.requireExecutableOperation(input.operationId);
|
||||
@@ -146,10 +167,9 @@ export class SecureOperationExecution {
|
||||
);
|
||||
}
|
||||
if (
|
||||
descriptor.riskLevel !== 'R2' ||
|
||||
descriptor.operationId !== input.operationId ||
|
||||
(descriptor.riskLevel !== 'R2' && descriptor.riskLevel !== 'R3') ||
|
||||
descriptor.method !== 'POST' ||
|
||||
descriptor.pathTemplate !== '/api/network/register-auto' ||
|
||||
descriptor.requestContentType !== 'none' ||
|
||||
!Array.isArray(input.targets) ||
|
||||
input.targets.length !== 1
|
||||
)
|
||||
@@ -172,10 +192,34 @@ export class SecureOperationExecution {
|
||||
Array.isArray(parameters) ||
|
||||
Object.keys(parameters).some((key) => !['parameterSchemaId', 'fields'].includes(key)) ||
|
||||
parameters.parameterSchemaId !== descriptor.parameterSchemaId ||
|
||||
!Array.isArray(parameters.fields) ||
|
||||
parameters.fields.length !== 0
|
||||
!Array.isArray(parameters.fields)
|
||||
)
|
||||
this.validation();
|
||||
let bodyDigest = EMPTY_DIGEST;
|
||||
let contentType = '';
|
||||
let serializedBody: string | undefined;
|
||||
if (ZERO_BODY_OPERATIONS.has(descriptor.operationId)) {
|
||||
if (descriptor.requestContentType !== 'none' || parameters.fields.length !== 0) this.validation();
|
||||
} else if (descriptor.operationId === SYSTEM_REBOOT_OPERATION) {
|
||||
if (descriptor.requestContentType !== 'application/json' || parameters.fields.length !== 1)
|
||||
this.validation();
|
||||
const field = parameters.fields[0] as { fieldId?: unknown; kind?: unknown; value?: unknown };
|
||||
if (
|
||||
!field ||
|
||||
typeof field !== 'object' ||
|
||||
Array.isArray(field) ||
|
||||
Object.keys(field).some((key) => !['fieldId', 'kind', 'value'].includes(key)) ||
|
||||
field.fieldId !== 'delay_seconds' ||
|
||||
field.kind !== 'number' ||
|
||||
field.value !== SYSTEM_REBOOT_DELAY_SECONDS
|
||||
)
|
||||
this.validation();
|
||||
serializedBody = JSON.stringify({ delay_seconds: SYSTEM_REBOOT_DELAY_SECONDS });
|
||||
bodyDigest = digest(serializedBody);
|
||||
contentType = 'application/json';
|
||||
} else {
|
||||
this.validation();
|
||||
}
|
||||
const instance = this.options.db
|
||||
.prepare('SELECT base_url,config_revision FROM instances WHERE id=? AND enabled=1')
|
||||
.get(target.instanceId) as { base_url: string; config_revision: number } | undefined;
|
||||
@@ -216,10 +260,10 @@ export class SecureOperationExecution {
|
||||
descriptor.method,
|
||||
descriptor.pathTemplate,
|
||||
'',
|
||||
EMPTY_DIGEST,
|
||||
'',
|
||||
bodyDigest,
|
||||
contentType,
|
||||
descriptor.parameterSchemaId,
|
||||
parameterDigest(descriptor.parameterSchemaId),
|
||||
parameterDigest(descriptor.parameterSchemaId, parameters.fields),
|
||||
this.nonce(),
|
||||
digest(token),
|
||||
ACTOR,
|
||||
@@ -232,7 +276,7 @@ export class SecureOperationExecution {
|
||||
id,
|
||||
status: 'prepared',
|
||||
operationId: descriptor.operationId,
|
||||
risk: 'R2',
|
||||
risk: descriptor.riskLevel as 'R2' | 'R3',
|
||||
expiresAt,
|
||||
confirmationToken: token,
|
||||
confirmationPrompt: `Execute ${descriptor.title} on ${target.instanceId}?`,
|
||||
@@ -278,16 +322,28 @@ export class SecureOperationExecution {
|
||||
const instance = this.options.db
|
||||
.prepare('SELECT base_url,config_revision FROM instances WHERE id=? AND enabled=1')
|
||||
.get(row.target_instance_id) as { base_url: string; config_revision: number } | undefined;
|
||||
const expectedBody =
|
||||
descriptor.operationId === SYSTEM_REBOOT_OPERATION
|
||||
? JSON.stringify({ delay_seconds: SYSTEM_REBOOT_DELAY_SECONDS })
|
||||
: undefined;
|
||||
const expectedBodyDigest = expectedBody ? digest(expectedBody) : EMPTY_DIGEST;
|
||||
const expectedContentType =
|
||||
descriptor.operationId === SYSTEM_REBOOT_OPERATION ? 'application/json' : '';
|
||||
const expectedFields =
|
||||
descriptor.operationId === SYSTEM_REBOOT_OPERATION
|
||||
? [{ fieldId: 'delay_seconds', kind: 'number', value: SYSTEM_REBOOT_DELAY_SECONDS }]
|
||||
: [];
|
||||
const bindingValid =
|
||||
descriptor.operationId === ALLOWED_OPERATION &&
|
||||
descriptor.riskLevel === 'R2' &&
|
||||
!!EXECUTABLE_OPERATIONS[descriptor.operationId] &&
|
||||
(descriptor.riskLevel === 'R2' || descriptor.riskLevel === 'R3') &&
|
||||
descriptor.riskLevel === row.risk_level &&
|
||||
descriptor.method === row.method &&
|
||||
descriptor.pathTemplate === row.path &&
|
||||
descriptor.parameterSchemaId === row.parameter_schema_id &&
|
||||
row.canonical_query === '' &&
|
||||
row.content_type === '' &&
|
||||
row.body_digest === EMPTY_DIGEST &&
|
||||
row.parameters_digest === parameterDigest(row.parameter_schema_id) &&
|
||||
row.content_type === expectedContentType &&
|
||||
row.body_digest === expectedBodyDigest &&
|
||||
row.parameters_digest === parameterDigest(row.parameter_schema_id, expectedFields) &&
|
||||
!!row.nonce &&
|
||||
!!instance &&
|
||||
instance.base_url === row.target_origin &&
|
||||
@@ -310,12 +366,13 @@ export class SecureOperationExecution {
|
||||
.prepare(
|
||||
`INSERT INTO jobs
|
||||
(id,parent_job_id,root_job_id,retry_of_job_id,operation_id,risk_level,status,requested_by,request_id,parameters_digest,created_at,started_at,updated_at)
|
||||
VALUES (?,NULL,?,NULL,?,'R2','running',?,?,?,?,?,?)`,
|
||||
VALUES (?,NULL,?,NULL,?,?,'running',?,?,?,?,?,?)`,
|
||||
)
|
||||
.run(
|
||||
ids.job,
|
||||
ids.job,
|
||||
row.operation_id,
|
||||
row.risk_level,
|
||||
actor,
|
||||
requestId,
|
||||
row.parameters_digest,
|
||||
@@ -350,8 +407,11 @@ export class SecureOperationExecution {
|
||||
method: 'POST',
|
||||
path: bound.path,
|
||||
query: '',
|
||||
contentType: '',
|
||||
body: undefined,
|
||||
contentType: bound.content_type,
|
||||
body:
|
||||
bound.operation_id === SYSTEM_REBOOT_OPERATION
|
||||
? JSON.stringify({ delay_seconds: SYSTEM_REBOOT_DELAY_SECONDS })
|
||||
: undefined,
|
||||
});
|
||||
state = response.status >= 200 && response.status < 300 ? 'succeeded' : 'failed';
|
||||
code = state === 'succeeded' ? 'UPSTREAM_SUCCEEDED' : 'UPSTREAM_REJECTED';
|
||||
@@ -373,9 +433,9 @@ export class SecureOperationExecution {
|
||||
return this.options.db.transaction(() => {
|
||||
const jobs = this.options.db
|
||||
.prepare(
|
||||
"SELECT id FROM jobs WHERE operation_id=? AND risk_level='R2' AND status='running'",
|
||||
"SELECT id FROM jobs WHERE operation_id IN ('postNetworkRegisterAuto','postServiceRestart','postSystemReboot') AND risk_level IN ('R2','R3') AND status='running'",
|
||||
)
|
||||
.all(ALLOWED_OPERATION) as Array<{ id: string }>;
|
||||
.all() as Array<{ id: string }>;
|
||||
for (const row of jobs) this.finishByJob(row.id, now, 'unknown-result', 'INTERRUPTED');
|
||||
return jobs.length;
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user