From 9ea8021cceb22b220c826252475c752bd0239c42 Mon Sep 17 00:00:00 2001 From: chick Date: Tue, 21 Jul 2026 22:56:55 +0800 Subject: [PATCH] feat(fleet): card-only UI with progressive load and restart ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../secure-operation-execution.test.ts | 73 +++ .../operations/secure-operation-execution.ts | 136 +++-- apps/api/src/control-plane.operations.test.ts | 8 + apps/api/src/control-plane.test.ts | 6 + apps/api/src/control-plane.ts | 21 +- .../safe-control-plane-upstream.test.ts | 14 + .../transport/safe-control-plane-upstream.ts | 4 + .../transport/safe-upstream-gateway.test.ts | 23 + .../transport/safe-upstream-gateway.ts | 37 +- apps/api/src/production-control-plane.test.ts | 2 + apps/web/src/app-shell.integration.test.tsx | 62 ++- apps/web/src/app-shell.tsx | 55 +- .../src/fleet/fleet-api-data-source.test.ts | 52 +- apps/web/src/fleet/fleet-api-data-source.ts | 176 ++++-- apps/web/src/fleet/fleet-page.test.tsx | 68 ++- apps/web/src/fleet/fleet-page.tsx | 527 ++++++++++++------ apps/web/src/fleet/fleet-table-view-model.ts | 2 + .../src/instances/overview-system.test.tsx | 8 +- apps/web/src/instances/overview-system.tsx | 166 +++++- apps/web/src/styles.css | 113 ++++ 20 files changed, 1207 insertions(+), 346 deletions(-) diff --git a/apps/api/src/application/operations/secure-operation-execution.test.ts b/apps/api/src/application/operations/secure-operation-execution.test.ts index cd5b8a5..ffabd20 100644 --- a/apps/api/src/application/operations/secure-operation-execution.test.ts +++ b/apps/api/src/application/operations/secure-operation-execution.test.ts @@ -213,3 +213,76 @@ describe('SecureOperationExecution generic R2 slice', () => { }); }); }); + +describe('SecureOperationExecution R3 restart slice', () => { + it('prepares and executes service restart as a zero-body R3 operation', async () => { + const { db, execution, request } = fixture(); + const prepared = await execution.prepare({ + operationId: 'postServiceRestart', + targets: [{ instanceId: 'i-1', revision: 3 }], + parameters: { + parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1', + fields: [], + }, + }); + expect(prepared).toMatchObject({ + operationId: 'postServiceRestart', + risk: 'R3', + status: 'prepared', + targetCount: 1, + }); + const job = await execution.execute( + { preparationId: prepared.id, confirmationToken: prepared.confirmationToken }, + 'actor', + 'r-restart', + ); + expect(job).toMatchObject({ operationId: 'postServiceRestart', status: 'succeeded' }); + expect(request).toHaveBeenCalledWith({ + origin: 'http://192.168.1.10', + method: 'POST', + path: '/api/service/restart', + query: '', + contentType: '', + body: undefined, + }); + expect( + db.prepare('SELECT risk_level FROM jobs WHERE id=?').get(job.id), + ).toEqual({ risk_level: 'R3' }); + }); + + it('prepares and executes system reboot only with fixed delay_seconds=3', async () => { + const { execution, request } = fixture(); + await expect( + execution.prepare({ + operationId: 'postSystemReboot', + targets: [{ instanceId: 'i-1', revision: 3 }], + parameters: { + parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1', + fields: [{ fieldId: 'delay_seconds', kind: 'number', value: 1 }], + }, + } as never), + ).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }); + const prepared = await execution.prepare({ + operationId: 'postSystemReboot', + targets: [{ instanceId: 'i-1', revision: 3 }], + parameters: { + parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1', + fields: [{ fieldId: 'delay_seconds', kind: 'number', value: 3 }], + }, + }); + expect(prepared.risk).toBe('R3'); + await execution.execute( + { preparationId: prepared.id, confirmationToken: prepared.confirmationToken }, + 'actor', + 'r-reboot', + ); + expect(request).toHaveBeenCalledWith({ + origin: 'http://192.168.1.10', + method: 'POST', + path: '/api/system/reboot', + query: '', + contentType: 'application/json', + body: '{"delay_seconds":3}', + }); + }); +}); diff --git a/apps/api/src/application/operations/secure-operation-execution.ts b/apps/api/src/application/operations/secure-operation-execution.ts index 830b9ea..873478e 100644 --- a/apps/api/src/application/operations/secure-operation-execution.ts +++ b/apps/api/src/application/operations/secure-operation-execution.ts @@ -18,22 +18,47 @@ export interface SecureOperationDescriptor { export interface SecureOperationRegistry { requireExecutableOperation(operationId: string): SecureOperationDescriptor; } +const EXECUTABLE_OPERATIONS: Readonly> = 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; })(); diff --git a/apps/api/src/control-plane.operations.test.ts b/apps/api/src/control-plane.operations.test.ts index edfb1b3..b1cb732 100644 --- a/apps/api/src/control-plane.operations.test.ts +++ b/apps/api/src/control-plane.operations.test.ts @@ -23,6 +23,14 @@ it('registers the read-only operations catalog without touching upstream', async upstreamCalls += 1; throw new Error('unexpected'); }, + postServiceRestart: async () => { + upstreamCalls += 1; + throw new Error('unexpected'); + }, + postSystemReboot: async () => { + upstreamCalls += 1; + throw new Error('unexpected'); + }, }, }); const response = await app.inject('/api/v1/operations?pageSize=1'); diff --git a/apps/api/src/control-plane.test.ts b/apps/api/src/control-plane.test.ts index 9677496..99bc2b6 100644 --- a/apps/api/src/control-plane.test.ts +++ b/apps/api/src/control-plane.test.ts @@ -35,6 +35,8 @@ describe('buildControlPlaneApp', () => { get: async () => ({ status: 200, headers: {}, body: '' }), request: async () => ({ status: 200, headers: {}, body: '' }), postNetworkRegisterAuto: async () => ({ status: 200 }), + postServiceRestart: async () => ({ status: 200 }), + postSystemReboot: async () => ({ status: 200 }), }, }); @@ -64,6 +66,8 @@ describe('buildControlPlaneApp', () => { get: async () => ({ status: 200, headers: {}, body: '' }), request: async () => ({ status: 200, headers: {}, body: '' }), postNetworkRegisterAuto: async () => ({ status: 200 }), + postServiceRestart: async () => ({ status: 200 }), + postSystemReboot: async () => ({ status: 200 }), }, authenticateEventStream: (request) => request.headers.authorization === 'Bearer allowed', }); @@ -150,6 +154,8 @@ describe('buildControlPlaneApp', () => { }, request: async () => ({ status: 200, headers: {}, body: '' }), postNetworkRegisterAuto: async () => ({ status: 200 }), + postServiceRestart: async () => ({ status: 200 }), + postSystemReboot: async () => ({ status: 200 }), }, }); const created = await app.inject({ diff --git a/apps/api/src/control-plane.ts b/apps/api/src/control-plane.ts index 9aaf58d..ad46d19 100644 --- a/apps/api/src/control-plane.ts +++ b/apps/api/src/control-plane.ts @@ -36,6 +36,8 @@ import { InstanceMessageService } from './application/messages/instance-message- export interface SafeControlPlaneUpstream extends ConnectionTransport { request: UpstreamSessionClientOptions['request']; postNetworkRegisterAuto(origin: string): Promise<{ readonly status: number }>; + postServiceRestart(origin: string): Promise<{ readonly status: number }>; + postSystemReboot(origin: string, delaySeconds: number): Promise<{ readonly status: number }>; } export interface ControlPlaneOptions { readonly db: Database.Database; @@ -87,9 +89,22 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane db: options.db, registry: secureOperationRegistry, transport: { - request: async ({ origin }) => { - const response = await options.upstream.postNetworkRegisterAuto(origin); - return { status: response.status }; + request: async ({ origin, path, body, contentType }) => { + if (path === '/api/network/register-auto') { + const response = await options.upstream.postNetworkRegisterAuto(origin); + return { status: response.status }; + } + if (path === '/api/service/restart') { + const response = await options.upstream.postServiceRestart(origin); + return { status: response.status }; + } + if (path === '/api/system/reboot') { + if (contentType !== 'application/json' || body !== JSON.stringify({ delay_seconds: 3 })) + throw new Error('UPSTREAM_REQUEST_INVALID'); + const response = await options.upstream.postSystemReboot(origin, 3); + return { status: response.status }; + } + throw new Error('UPSTREAM_REQUEST_INVALID'); }, }, ...(options.now ? { now: options.now } : {}), diff --git a/apps/api/src/infrastructure/transport/safe-control-plane-upstream.test.ts b/apps/api/src/infrastructure/transport/safe-control-plane-upstream.test.ts index 3e347b6..5f9d253 100644 --- a/apps/api/src/infrastructure/transport/safe-control-plane-upstream.test.ts +++ b/apps/api/src/infrastructure/transport/safe-control-plane-upstream.test.ts @@ -23,6 +23,8 @@ describe('createSafeControlPlaneUpstream', () => { body: '[REDACTED]', }); await upstream.postNetworkRegisterAuto('http://192.168.1.20'); + await upstream.postServiceRestart('http://192.168.1.20'); + await upstream.postSystemReboot('http://192.168.1.20', 3); expect(calls).toEqual([ { method: 'GET', url: 'http://192.168.1.20/api/health' }, { @@ -37,6 +39,18 @@ describe('createSafeControlPlaneUpstream', () => { headers: {}, body: '', }, + { + method: 'POST', + url: 'http://192.168.1.20/api/service/restart', + headers: {}, + body: '', + }, + { + method: 'POST', + url: 'http://192.168.1.20/api/system/reboot', + headers: { 'content-type': 'application/json' }, + body: '{"delay_seconds":3}', + }, ]); }); }); diff --git a/apps/api/src/infrastructure/transport/safe-control-plane-upstream.ts b/apps/api/src/infrastructure/transport/safe-control-plane-upstream.ts index 55184ac..2302238 100644 --- a/apps/api/src/infrastructure/transport/safe-control-plane-upstream.ts +++ b/apps/api/src/infrastructure/transport/safe-control-plane-upstream.ts @@ -5,6 +5,8 @@ import { SafeUpstreamGateway, type SafeUpstreamTransport } from './safe-upstream export interface SafeControlPlaneUpstream extends ConnectionTransport { request: UpstreamSessionClientOptions['request']; postNetworkRegisterAuto(origin: string): Promise<{ readonly status: number }>; + postServiceRestart(origin: string): Promise<{ readonly status: number }>; + postSystemReboot(origin: string, delaySeconds: number): Promise<{ readonly status: number }>; } export function createSafeControlPlaneUpstream( transport: SafeUpstreamTransport, @@ -14,5 +16,7 @@ export function createSafeControlPlaneUpstream( get: (url) => transport.get(url), request: (request) => gateway.request(request), postNetworkRegisterAuto: (origin) => gateway.postNetworkRegisterAuto(origin), + postServiceRestart: (origin) => gateway.postServiceRestart(origin), + postSystemReboot: (origin, delaySeconds) => gateway.postSystemReboot(origin, delaySeconds), }; } diff --git a/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts b/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts index a56cc78..66800fd 100644 --- a/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts +++ b/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts @@ -161,4 +161,27 @@ describe('SafeUpstreamGateway', () => { }), ).rejects.toThrow('UPSTREAM_REQUEST_INVALID'); }); + + it('dispatches audited service restart and system reboot with fixed delay body', async () => { + const calls: unknown[] = []; + const gateway = new SafeUpstreamGateway({ + transport: { + get: async () => ({ status: 200, headers: {}, body: '' }), + post: async (url, headers, body) => { + calls.push({ url, headers, body }); + return { status: 204, headers: {}, body: '' }; + }, + }, + }); + await gateway.postServiceRestart('http://192.168.1.20:8080'); + await gateway.postSystemReboot('http://192.168.1.20:8080', 3); + expect(calls).toEqual([ + { url: 'http://192.168.1.20:8080/api/service/restart', headers: {}, body: '' }, + { + url: 'http://192.168.1.20:8080/api/system/reboot', + headers: { 'content-type': 'application/json' }, + body: '{"delay_seconds":3}', + }, + ]); + }); }); diff --git a/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts b/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts index 045bd78..9b28338 100644 --- a/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts +++ b/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts @@ -16,7 +16,7 @@ export interface SafeUpstreamTransport { } export class SafeUpstreamGateway { constructor(private readonly options: { readonly transport: SafeUpstreamTransport }) {} - async postNetworkRegisterAuto(origin: string): Promise { + private assertOrigin(origin: string): string { let parsed: URL; try { parsed = new URL(origin); @@ -32,7 +32,12 @@ export class SafeUpstreamGateway { parsed.hash ) throw this.notDispatched(); - const url = `${parsed.origin}/api/network/register-auto`; + return parsed.origin; + } + + private async postZeroBody(origin: string, path: string): Promise { + const base = this.assertOrigin(origin); + const url = `${base}${path}`; try { return await this.options.transport.post(url, {}, ''); } catch (error) { @@ -44,6 +49,34 @@ export class SafeUpstreamGateway { throw error; } } + + async postNetworkRegisterAuto(origin: string): Promise { + return this.postZeroBody(origin, '/api/network/register-auto'); + } + + async postServiceRestart(origin: string): Promise { + return this.postZeroBody(origin, '/api/service/restart'); + } + + async postSystemReboot(origin: string, delaySeconds: number): Promise { + if (delaySeconds !== 3) throw this.notDispatched(); + const base = this.assertOrigin(origin); + const url = `${base}/api/system/reboot`; + try { + return await this.options.transport.post( + url, + { 'content-type': 'application/json' }, + JSON.stringify({ delay_seconds: 3 }), + ); + } catch (error) { + if ( + error instanceof UpstreamError && + (error.code === 'UNSAFE_ORIGIN' || error.code === 'UNSAFE_RESOLUTION') + ) + throw new OperationNotDispatchedError(error.code); + throw error; + } + } async request(request: UpstreamRequest): Promise { const url = new URL(request.url); const headerKeys = Object.keys(request.headers).sort().join(','); diff --git a/apps/api/src/production-control-plane.test.ts b/apps/api/src/production-control-plane.test.ts index 42d62e9..5b2362f 100644 --- a/apps/api/src/production-control-plane.test.ts +++ b/apps/api/src/production-control-plane.test.ts @@ -31,6 +31,8 @@ async function fixtureOptions(): Promise { get: async () => ({ status: 200, headers: {}, body: '' }), request: async () => ({ status: 200, headers: {}, body: '' }), postNetworkRegisterAuto: async () => ({ status: 200 }), + postServiceRestart: async () => ({ status: 200 }), + postSystemReboot: async () => ({ status: 200 }), }, keychainMetadataCheck: async () => true, }; diff --git a/apps/web/src/app-shell.integration.test.tsx b/apps/web/src/app-shell.integration.test.tsx index 9800e0c..609b7fe 100644 --- a/apps/web/src/app-shell.integration.test.tsx +++ b/apps/web/src/app-shell.integration.test.tsx @@ -82,12 +82,14 @@ describe('React AppShell and Fleet vertical slice', () => { ); resolve(snapshot); - const alpha = await screen.findByRole('row', { name: /Alpha/ }); - expect(within(alpha).getByRole('link', { name: 'Alpha' }).getAttribute('href')).toBe( - '/instances/alpha/overview', - ); + const alpha = await screen.findByRole('article', { name: /Alpha 实例概览/ }); expect( - within(screen.getByRole('row', { name: /Bravo/ })).getByText('http://bravo.example:8080'), + within(alpha).getByRole('link', { name: /打开 Alpha 实例仪表盘/ }).getAttribute('href'), + ).toBe('/instances/alpha/overview'); + expect( + within(screen.getByRole('article', { name: /Bravo 实例概览/ })).getByText( + 'http://bravo.example:8080', + ), ).toBeTruthy(); expect(screen.getByText('版本 0.1.0')).toBeTruthy(); expect( @@ -280,18 +282,18 @@ describe('React AppShell and Fleet vertical slice', () => { it('supports accessible search, status filtering, sorting, and visible selection', async () => { const user = userEvent.setup(); render( snapshot)} />); - await screen.findByRole('row', { name: /Alpha/ }); + await screen.findByRole('article', { name: /Alpha 实例概览/ }); - await user.click(screen.getByRole('button', { name: /按延迟排序/i })); - let rows = screen.getAllByRole('row').slice(1); - expect(rows[0]?.textContent).toContain('Alpha'); - await user.click(screen.getByRole('button', { name: /按延迟排序/i })); - rows = screen.getAllByRole('row').slice(1); - expect(rows[0]?.textContent).toContain('Bravo'); + await user.selectOptions(screen.getByRole('combobox', { name: '排序' }), 'latency:asc'); + let cards = screen.getAllByRole('article'); + expect(cards[0]?.textContent).toContain('Alpha'); + await user.selectOptions(screen.getByRole('combobox', { name: '排序' }), 'latency:desc'); + cards = screen.getAllByRole('article'); + expect(cards[0]?.textContent).toContain('Bravo'); await user.selectOptions(screen.getByRole('combobox', { name: '状态' }), 'auth'); - expect(screen.queryByRole('row', { name: /Alpha/ })).toBeNull(); - await user.click(screen.getByRole('checkbox', { name: '选择当前页全部实例' })); + expect(screen.queryByRole('article', { name: /Alpha 实例概览/ })).toBeNull(); + await user.click(screen.getByRole('button', { name: '全选本页' })); expect((screen.getByRole('checkbox', { name: '选择 Bravo' }) as HTMLInputElement).checked).toBe( true, ); @@ -314,7 +316,7 @@ describe('React AppShell and Fleet vertical slice', () => { expect((await screen.findByRole('alert')).textContent).toContain('实例加载失败'); await user.click(screen.getByRole('button', { name: '重试加载实例' })); - expect(await screen.findByRole('row', { name: /Unsafe/ })).toBeTruthy(); + expect(await screen.findByRole('article', { name: /Unsafe 实例概览/ })).toBeTruthy(); expect(screen.queryByRole('link', { name: '打开 Unsafe 的源站' })).toBeNull(); first.unmount(); @@ -328,28 +330,30 @@ describe('React AppShell and Fleet vertical slice', () => { unmount(); }); - it('supports metadata filters, column visibility, pagination, and a batch-action entry', async () => { + it('supports metadata filters, card anomalies, and a batch-action entry', async () => { const user = userEvent.setup(); render( snapshot)} />); - await screen.findByRole('row', { name: /Alpha/ }); + await screen.findByRole('article', { name: /Alpha 实例概览/ }); await user.selectOptions(screen.getByRole('combobox', { name: '能力' }), 'sms'); - expect(screen.queryByRole('row', { name: /Alpha/ })).toBeNull(); - expect(screen.getByRole('row', { name: /Bravo/ }).textContent).toContain('clock drift'); - - await user.click(screen.getByRole('button', { name: '选择列' })); - await user.click(screen.getByRole('checkbox', { name: '显示异常列' })); - expect(screen.queryByRole('columnheader', { name: '异常' })).toBeNull(); + expect(screen.queryByRole('article', { name: /Alpha 实例概览/ })).toBeNull(); + expect(screen.getByRole('article', { name: /Bravo 实例概览/ }).textContent).toContain( + 'clock drift', + ); + expect(screen.queryByRole('heading', { name: '高级详细列表' })).toBeNull(); + expect(screen.queryByRole('table')).toBeNull(); await user.click(screen.getByRole('checkbox', { name: '选择 Bravo' })); expect((screen.getByRole('button', { name: '批量操作' }) as HTMLButtonElement).disabled).toBe( false, ); await user.click(screen.getByRole('button', { name: '批量操作' })); - expect(screen.getByText('请为已选择的 1 项选择操作。')).toBeTruthy(); + expect(screen.getByText('已选择 1 项。重启将逐个实例安全确认后执行。')).toBeTruthy(); + expect(screen.getByRole('button', { name: '批量重启服务' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '批量系统重启' })).toBeTruthy(); }); - it('paginates fleet rows and selects only the current page', async () => { + it('paginates fleet cards and selects only the current page', async () => { const many: FleetSnapshot = { instances: Array.from({ length: 11 }, (_, index) => ({ id: `instance-${String(index + 1).padStart(2, '0')}`, @@ -359,14 +363,14 @@ describe('React AppShell and Fleet vertical slice', () => { statuses: new Map(), }; render( many)} />); - await screen.findByRole('row', { name: /Instance 01/ }); + await screen.findByRole('article', { name: /Instance 01 实例概览/ }); - expect(screen.queryByRole('row', { name: /Instance 11/ })).toBeNull(); + expect(screen.queryByRole('article', { name: /Instance 11 实例概览/ })).toBeNull(); expect(screen.getByText('第 1 页,共 2 页')).toBeTruthy(); - fireEvent.click(screen.getByRole('checkbox', { name: '选择当前页全部实例' })); + fireEvent.click(screen.getByRole('button', { name: '全选本页' })); expect(screen.getByText('已选择 10 项')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '下一页' })); - expect(await screen.findByRole('row', { name: /Instance 11/ })).toBeTruthy(); + expect(await screen.findByRole('article', { name: /Instance 11 实例概览/ })).toBeTruthy(); expect(screen.getByText('第 2 页,共 2 页')).toBeTruthy(); }); diff --git a/apps/web/src/app-shell.tsx b/apps/web/src/app-shell.tsx index c7d9642..78709f6 100644 --- a/apps/web/src/app-shell.tsx +++ b/apps/web/src/app-shell.tsx @@ -78,6 +78,8 @@ export interface InstanceContext { status: 'online' | 'offline' | 'auth-required' | 'degraded' | 'unknown'; authentication: 'authenticated' | 'auth-required' | 'unknown'; freshness: 'fresh' | 'stale' | 'expired' | 'unknown'; + /** Control-plane config revision for prepare/execute CAS when known. */ + revision?: number; resources?: Readonly<{ cpuPercent?: number; memoryPercent?: number; @@ -283,6 +285,7 @@ function Page({ instance={instance} {...(overviewDataSource ? { dataSource: overviewDataSource } : {})} refreshSignal={detailRefreshSignal} + {...(instance.revision !== undefined ? { revision: instance.revision } : {})} /> ), } @@ -450,10 +453,51 @@ export function AppShell({ const controller = new AbortController(); let active = true; setInstanceLoading(true); - const fleetLoad = (fleetDataSource ?? defaultFleetDataSource).load(controller.signal).then( - (fleet) => ({ ok: true as const, fleet }), - () => ({ ok: false as const }), - ); + const fleetLoad = (fleetDataSource ?? defaultFleetDataSource) + .load(controller.signal, (partial) => { + if (!active) return; + const fleetOwner = partial.instances.find((candidate) => candidate.id === routeInstanceId); + const status = partial.statuses.get(routeInstanceId); + if (!fleetOwner && !status) return; + setLoadedInstance((current) => { + if (!current || current.id !== routeInstanceId) return current; + const routeStatus = !status + ? current.status + : !status.reachable + ? 'offline' + : status.authenticated === false + ? 'auth-required' + : 'online'; + const freshness = status?.summary?.freshness; + const nextRevision = fleetOwner?.revision ?? current.revision; + return { + ...current, + origin: fleetOwner?.url ?? current.origin, + status: routeStatus, + authentication: + status?.authenticated === true + ? 'authenticated' + : status?.authenticated === false + ? 'auth-required' + : current.authentication, + freshness: + freshness === 'fresh' || freshness === 'stale' || freshness === 'expired' + ? freshness + : current.freshness, + ...(nextRevision === undefined ? {} : { revision: nextRevision }), + ...(status?.summary?.resources ? { resources: status.summary.resources } : {}), + resourceSummaryState: status?.summary?.resources + ? 'ready' + : current.resourceSummaryState === 'ready' + ? 'ready' + : 'loading', + }; + }); + }) + .then( + (fleet) => ({ ok: true as const, fleet }), + () => ({ ok: false as const }), + ); void resolvedInstanceDataSource.get(routeInstanceId).then( (owner) => { if (!active) return; @@ -469,6 +513,7 @@ export function AppShell({ status: 'unknown', authentication: 'unknown', freshness: 'unknown', + ...(owner.revision === undefined ? {} : { revision: owner.revision }), resourceSummaryState: 'loading', }); setInstanceLoading(false); @@ -494,6 +539,7 @@ export function AppShell({ ? 'auth-required' : 'online'; const freshness = status?.summary?.freshness; + const nextRevision = fleetOwner?.revision ?? owner.revision; setLoadedInstance({ id: owner.id, name: owner.name, @@ -509,6 +555,7 @@ export function AppShell({ freshness === 'fresh' || freshness === 'stale' || freshness === 'expired' ? freshness : 'unknown', + ...(nextRevision === undefined ? {} : { revision: nextRevision }), ...(status?.summary?.resources ? { resources: status.summary.resources } : {}), resourceSummaryState: status ? 'ready' : 'unavailable', }); diff --git a/apps/web/src/fleet/fleet-api-data-source.test.ts b/apps/web/src/fleet/fleet-api-data-source.test.ts index b9f95fb..1320341 100644 --- a/apps/web/src/fleet/fleet-api-data-source.test.ts +++ b/apps/web/src/fleet/fleet-api-data-source.test.ts @@ -22,7 +22,7 @@ describe('Fleet API data source', () => { name: 'Alpha', origin: 'https://alpha.example/admin', tags: ['lab'], - revision: 1, + revision: 4, capabilityStatus: 'unknown', freshness: 'unknown', credentialConfigured: false, @@ -36,7 +36,7 @@ describe('Fleet API data source', () => { ); const snapshot = await createFleetApiDataSource(fetcher as typeof fetch).load(); expect(snapshot.instances).toEqual([ - { id: 'alpha', name: 'Alpha', url: 'https://alpha.example/admin', tags: ['lab'] }, + { id: 'alpha', name: 'Alpha', url: 'https://alpha.example/admin', tags: ['lab'], revision: 4 }, ]); expect(snapshot.statuses.get('alpha')?.summary?.resources).toEqual({ cpuPercent: 23.4, @@ -54,6 +54,54 @@ describe('Fleet API data source', () => { ); }); + it('emits a partial fleet snapshot before resource enrichment completes', async () => { + let resolveResources!: (value: Response) => void; + const resourcesPromise = new Promise((resolve) => { + resolveResources = resolve; + }); + const fetcher = vi.fn(async (input: RequestInfo | URL) => { + if (String(input).endsWith('/resources')) return resourcesPromise; + return new Response( + JSON.stringify({ + items: [ + { + id: 'alpha', + name: 'Alpha', + origin: 'https://alpha.example/admin', + tags: [], + revision: 1, + capabilityStatus: 'unknown', + freshness: 'unknown', + credentialConfigured: false, + }, + ], + page: { page: 1, pageSize: 20, total: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }); + const partials: unknown[] = []; + const pending = createFleetApiDataSource(fetcher as typeof fetch).load(undefined, (snapshot) => { + partials.push({ + resources: snapshot.statuses.get('alpha')?.summary?.resources, + freshness: snapshot.statuses.get('alpha')?.summary?.freshness, + }); + }); + await vi.waitFor(() => expect(partials.length).toBe(1)); + expect(partials[0]).toEqual({ resources: undefined, freshness: 'unknown' }); + resolveResources( + new Response(JSON.stringify({ cpuPercent: 11, memoryPercent: 22 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + await pending; + expect(partials.at(-1)).toEqual({ + resources: { cpuPercent: 11, memoryPercent: 22 }, + freshness: 'fresh', + }); + }); + it('rejects legacy or malformed envelopes instead of rendering a false empty fleet', async () => { const fetcher = vi.fn(async () => new Response(JSON.stringify({ data: [] }), { status: 200 })); await expect(createFleetApiDataSource(fetcher as typeof fetch).load()).rejects.toThrow( diff --git a/apps/web/src/fleet/fleet-api-data-source.ts b/apps/web/src/fleet/fleet-api-data-source.ts index 6936109..5efd310 100644 --- a/apps/web/src/fleet/fleet-api-data-source.ts +++ b/apps/web/src/fleet/fleet-api-data-source.ts @@ -1,88 +1,142 @@ import type { FleetDataSource, FleetSnapshot } from './fleet-page.js'; import type { FleetInstance, FleetStatus } from './fleet-table-view-model.js'; -interface InstancePage { - readonly items?: readonly unknown[]; -} +const integer = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value); +const finite = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); +const string = (value: unknown): value is string => typeof value === 'string' && value.length > 0; +const record = (value: unknown): Record | undefined => + typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; -function parseInstance(value: unknown): FleetInstance { - if (value === null || typeof value !== 'object' || Array.isArray(value)) - throw new Error('Fleet response is invalid.'); - const item = value as Record; +function parseInstance(value: unknown): FleetInstance | undefined { + const item = record(value); if ( - typeof item.id !== 'string' || - typeof item.name !== 'string' || - typeof item.origin !== 'string' || + !item || + !string(item.id) || + !string(item.name) || + !string(item.origin) || !Array.isArray(item.tags) || !item.tags.every((tag) => typeof tag === 'string') ) - throw new Error('Fleet response is invalid.'); - return { id: item.id, name: item.name, url: item.origin, tags: item.tags }; + return undefined; + const revision = + integer(item.revision) && item.revision > 0 ? (item.revision as number) : undefined; + return { + id: item.id, + name: item.name, + url: item.origin, + tags: item.tags as string[], + ...(revision === undefined ? {} : { revision }), + }; } -type Resources = NonNullable['resources']>; -function parseResources(value: unknown): Resources { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - const item = value as Record; - const number = (field: string): number | undefined => { - const fieldValue = item[field]; - return typeof fieldValue === 'number' && Number.isFinite(fieldValue) ? fieldValue : undefined; - }; - const cpuPercent = number('cpuPercent'); - const memoryPercent = number('memoryPercent'); - const maxTemperatureCelsius = number('maxTemperatureCelsius'); - const phoneNumbers = Array.isArray(item.phoneNumbers) - ? item.phoneNumbers.filter((phone): phone is string => typeof phone === 'string') - : undefined; +function parseResources(value: unknown): NonNullable { + const body = record(value) ?? {}; + const resources: { + cpuPercent?: number; + memoryPercent?: number; + maxTemperatureCelsius?: number; + phoneNumbers?: string[]; + } = {}; + if (finite(body.cpuPercent)) resources.cpuPercent = body.cpuPercent; + if (finite(body.memoryPercent)) resources.memoryPercent = body.memoryPercent; + if (finite(body.maxTemperatureCelsius)) + resources.maxTemperatureCelsius = body.maxTemperatureCelsius; + if ( + Array.isArray(body.phoneNumbers) && + body.phoneNumbers.every((item) => typeof item === 'string' && item.length > 0) + ) + resources.phoneNumbers = body.phoneNumbers as string[]; return { - ...(cpuPercent === undefined ? {} : { cpuPercent }), - ...(memoryPercent === undefined ? {} : { memoryPercent }), - ...(maxTemperatureCelsius === undefined ? {} : { maxTemperatureCelsius }), - ...(phoneNumbers?.length ? { phoneNumbers } : {}), + freshness: 'fresh', + ...(Object.keys(resources).length > 0 ? { resources } : {}), }; } +async function readJson(response: Response): Promise { + try { + return await response.json(); + } catch { + return undefined; + } +} + +function skeletonStatuses(instances: readonly FleetInstance[]): Map { + return new Map( + instances.map((instance) => [ + instance.id, + { + reachable: true, + authenticated: true, + summary: { freshness: 'unknown' }, + }, + ]), + ); +} + export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDataSource { return { - async load(signal): Promise { - const response = await fetcher('/api/v1/instances', { + async load(signal, onPartial) { + const listResponse = await fetcher('/api/v1/instances', { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' }, ...(signal ? { signal } : {}), }); - if (!response.ok) throw new Error(`Fleet request failed (${response.status}).`); - const body = (await response.json()) as InstancePage; - if (!Array.isArray(body.items)) throw new Error('Fleet response is invalid.'); - const instances = body.items.map(parseInstance); - const entries = await Promise.all( - instances.map(async (instance): Promise => { - try { - const resourceResponse = await fetcher( - `/api/v1/instances/${encodeURIComponent(instance.id)}/resources`, - { - method: 'GET', - credentials: 'same-origin', - headers: { accept: 'application/json' }, - ...(signal ? { signal } : {}), - }, - ); - if (!resourceResponse.ok) - return [instance.id, { reachable: false, summary: { resources: {} } }]; - return [ - instance.id, - { + const listBody = record(await readJson(listResponse)); + if ( + !listResponse.ok || + !listBody || + !Array.isArray(listBody.items) || + !record(listBody.page) + ) + throw new Error('Fleet response is invalid.'); + const instances = listBody.items.map(parseInstance); + if (instances.some((item) => !item)) throw new Error('Fleet response is invalid.'); + const readyInstances = instances as FleetInstance[]; + const partial: FleetSnapshot = { + instances: readyInstances, + statuses: skeletonStatuses(readyInstances), + }; + onPartial?.(partial); + + const statuses = new Map( + await Promise.all( + readyInstances.map(async (instance) => { + try { + const response = await fetcher( + `/api/v1/instances/${encodeURIComponent(instance.id)}/resources`, + { + method: 'GET', + credentials: 'same-origin', + headers: { accept: 'application/json' }, + ...(signal ? { signal } : {}), + }, + ); + if (!response.ok) throw new Error('resource unavailable'); + const status: FleetStatus = { reachable: true, authenticated: true, - summary: { resources: parseResources(await resourceResponse.json()) }, - }, - ]; - } catch { - return [instance.id, { reachable: false, summary: { resources: {} } }]; - } - }), + summary: parseResources(await readJson(response)), + }; + return [instance.id, status] as const; + } catch { + const status: FleetStatus = { + reachable: true, + authenticated: true, + summary: { freshness: 'unknown' }, + }; + return [instance.id, status] as const; + } + }), + ), ); - return { instances, statuses: new Map(entries) }; + const complete: FleetSnapshot = { instances: readyInstances, statuses }; + onPartial?.(complete); + return complete; }, }; } diff --git a/apps/web/src/fleet/fleet-page.test.tsx b/apps/web/src/fleet/fleet-page.test.tsx index 20bb654..d40e018 100644 --- a/apps/web/src/fleet/fleet-page.test.tsx +++ b/apps/web/src/fleet/fleet-page.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { cleanup, render, screen, within } from '@testing-library/react'; -import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { FleetPage, type FleetSnapshot } from './fleet-page.js'; @@ -11,13 +11,13 @@ const snapshot: FleetSnapshot = { name: 'Alpha modem', url: 'http://192.168.1.2', tags: [], + revision: 3, }, ], statuses: new Map([ [ 'alpha', { - instanceId: 'alpha', reachable: true, authenticated: true, summary: { freshness: 'fresh', resources: { cpuPercent: 24, memoryPercent: 51 } }, @@ -52,3 +52,65 @@ describe('FleetPage card navigation', () => { expect(within(card).getByRole('group', { name: '实例管理操作' })).toBeTruthy(); }); }); + +describe('FleetPage batch and restart actions', () => { + it('exposes card restart controls and batch restart entry for selected instances', async () => { + const prepare = vi.fn(async () => ({ + id: 'prep-1', + status: 'prepared', + operationId: 'postServiceRestart', + risk: 'R3', + expiresAt: '2030-01-01T00:00:00.000Z', + confirmationPrompt: 'Confirm restart', + targetCount: 1, + })); + const execute = vi.fn(async () => ({ + id: 'job-1', + operationId: 'postServiceRestart', + status: 'succeeded', + rootJobId: 'job-1', + items: [{ id: 'item-1', targetId: 'alpha', state: 'succeeded' }], + attempts: [], + createdAt: '2029-01-01T00:00:00.000Z', + })); + const list = vi.fn(async () => ({ + items: [ + { + operationId: 'postServiceRestart', + title: 'Restart Service', + risk: 'R3', + capability: 'job', + batchable: false, + parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1', + }, + { + operationId: 'postSystemReboot', + title: 'Reboot System', + risk: 'R3', + capability: 'job', + batchable: false, + parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1', + }, + ], + page: { page: 1, pageSize: 100, total: 2 }, + })); + vi.stubGlobal('confirm', () => true); + render( + , + ); + const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' }); + expect(within(card).getByRole('button', { name: '重启服务 Alpha modem' })).toBeTruthy(); + expect(within(card).getByRole('button', { name: '系统重启 Alpha modem' })).toBeTruthy(); + fireEvent.click(within(card).getByRole('checkbox', { name: '选择 Alpha modem' })); + expect(screen.getByText(/已选择 1 项/).textContent).toMatch(/已选择 1 项/); + fireEvent.click(screen.getByRole('button', { name: '批量操作' })); + const batch = await screen.findByRole('region', { name: '批量操作入口' }); + expect(batch.textContent).toMatch(/已选择 1 项/); + fireEvent.click(screen.getByRole('button', { name: '批量重启服务' })); + await vi.waitFor(() => expect(prepare).toHaveBeenCalled()); + expect(execute).toHaveBeenCalledWith('prep-1'); + }); +}); diff --git a/apps/web/src/fleet/fleet-page.tsx b/apps/web/src/fleet/fleet-page.tsx index 74fa5c9..964d8d5 100644 --- a/apps/web/src/fleet/fleet-page.tsx +++ b/apps/web/src/fleet/fleet-page.tsx @@ -1,4 +1,4 @@ -import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { buildFleetTableViewModel, @@ -15,13 +15,21 @@ import type { FleetMessagesDataSource, } from './fleet-messages-api-data-source.js'; import { loadFleetMessageSummaries } from './fleet-messages-api-data-source.js'; +import { + createOperationClient, + type OperationClient, +} from '../operations/operation-client.js'; +import { safeUiError } from '../ui/locale.js'; export interface FleetSnapshot { readonly instances: readonly FleetInstance[]; readonly statuses: ReadonlyMap; } export interface FleetDataSource { - load(signal?: AbortSignal): Promise; + load( + signal?: AbortSignal, + onPartial?: (snapshot: FleetSnapshot) => void, + ): Promise; } export interface FleetPageProps { readonly dataSource?: FleetDataSource; @@ -29,6 +37,7 @@ export interface FleetPageProps { readonly instanceDataSource?: InstanceDataSource; readonly initialData?: FleetSnapshot; readonly refreshSignal?: number; + readonly operationClient?: OperationClient; } type FleetMessageState = FleetMessageLoadState; @@ -39,7 +48,7 @@ const STATUS_LABELS: Readonly> = { offline: '离线', unknown: '未知', }; -const COLUMN_LABELS: Readonly> = { +const SORT_COLUMN_LABELS: Readonly> = { name: '名称', status: '状态', latency: '延迟', @@ -48,9 +57,7 @@ const COLUMN_LABELS: Readonly> = { tags: '标签', freshness: '数据新鲜度', anomalies: '异常', - origin: '源地址', }; -const ALL_COLUMNS = Object.keys(COLUMN_LABELS) as readonly (FleetSortColumn | 'origin')[]; const CAPABILITY_LABELS: Readonly> = { overview: '概览', cellular: '蜂窝网络', @@ -62,11 +69,6 @@ const CAPABILITY_LABELS: Readonly> = { automation: '自动化', ota: 'OTA', }; -const FRESHNESS_LABELS: Readonly> = { - fresh: '最新', - stale: '可能过期', - unknown: '未知', -}; function capabilityLabel(value: string): string { return CAPABILITY_LABELS[value] ?? value; @@ -100,12 +102,35 @@ export function canonicalHttpOrigin(value: string): string | null { } } +type BatchActionKind = 'service-restart' | 'system-reboot'; +type BatchProgress = Readonly<{ + action: BatchActionKind; + total: number; + completed: number; + succeeded: number; + failed: number; + message: string; +}>; + +const SERVICE_RESTART = { + operationId: 'postServiceRestart', + parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1', + title: '重启服务', +} as const; +const SYSTEM_REBOOT = { + operationId: 'postSystemReboot', + parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1', + title: '系统重启', + delaySeconds: 3, +} as const; + export function FleetPage({ dataSource, messagesDataSource, instanceDataSource, initialData, refreshSignal = 0, + operationClient, }: FleetPageProps) { const [snapshot, setSnapshot] = useState(initialData ?? null); const [error, setError] = useState(null); @@ -122,9 +147,15 @@ export function FleetPage({ direction: 'asc', }); const [selectedIds, setSelectedIds] = useState>(new Set()); - const [shownColumns, setShownColumns] = useState>(new Set(ALL_COLUMNS)); - const [columnsOpen, setColumnsOpen] = useState(false); const [batchOpen, setBatchOpen] = useState(false); + const [batchBusy, setBatchBusy] = useState(false); + const [batchProgress, setBatchProgress] = useState(null); + const [cardAction, setCardAction] = useState<{ + id: string; + busy: boolean; + message?: string; + error?: string; + } | null>(null); const [deleteId, setDeleteId] = useState(); const [deleteOwner, setDeleteOwner] = useState>(); const [deleteConfirmation, setDeleteConfirmation] = useState(''); @@ -132,11 +163,14 @@ export function FleetPage({ const deleteRequestRef = useRef(0); const [deleteStatus, setDeleteStatus] = useState>(); - const selectAllRef = useRef(null); const messagesOwner = useRef(0); const [messageStates, setMessageStates] = useState>( new Map(), ); + const resolvedOperationClient = useMemo( + () => operationClient ?? createOperationClient(), + [operationClient], + ); useEffect(() => { if (initialData && refreshSignal === 0) { @@ -149,18 +183,27 @@ export function FleetPage({ return; } let active = true; - setSnapshot(null); + const controller = new AbortController(); + // Keep the last known instances visible while refreshing so the homepage stays interactive. + setSnapshot((current) => current); setError(null); - void dataSource.load().then( - (data) => { - if (active) setSnapshot(data); - }, - () => { - if (active) setError('实例加载失败。'); - }, - ); + void dataSource + .load(controller.signal, (partial) => { + if (active) setSnapshot(partial); + }) + .then( + (data) => { + if (active) setSnapshot(data); + }, + (reason: unknown) => { + if (!active) return; + if (reason instanceof DOMException && reason.name === 'AbortError') return; + setError('实例加载失败。'); + }, + ); return () => { active = false; + controller.abort(); }; }, [attempt, dataSource, initialData, refreshSignal]); @@ -209,11 +252,6 @@ export function FleetPage({ }; }, [snapshot]); - useEffect(() => { - if (selectAllRef.current) { - selectAllRef.current.indeterminate = model.visibleSelection.indeterminate; - } - }, [model.visibleSelection.indeterminate]); useEffect(() => { if (page !== model.page) setPage(model.page); }, [model.page, page]); @@ -222,13 +260,6 @@ export function FleetPage({ action(); setPage(1); } - function changeSort(column: FleetSortColumn): void { - setSort((current) => ({ - column, - direction: current.column === column && current.direction === 'asc' ? 'desc' : 'asc', - })); - setPage(1); - } function toggleOne(id: string, checked: boolean): void { setSelectedIds((current) => { const next = new Set(current); @@ -247,14 +278,6 @@ export function FleetPage({ return next; }); } - function toggleColumn(column: string): void { - setShownColumns((current) => { - const next = new Set(current); - if (next.has(column)) next.delete(column); - else next.add(column); - return next; - }); - } async function beginDelete(id: string): Promise { const request = ++deleteRequestRef.current; @@ -297,6 +320,155 @@ export function FleetPage({ } } + function selectedTargets(): Array<{ instanceId: string; revision: number }> { + const instances = snapshot?.instances ?? []; + return model.selectedIds.flatMap((id) => { + const instance = instances.find((item) => item.id === id); + if (!instance || !instance.revision || instance.revision < 1) return []; + return [{ instanceId: id, revision: instance.revision }]; + }); + } + + async function runOperation( + kind: BatchActionKind, + targets: ReadonlyArray<{ instanceId: string; revision: number }>, + ): Promise<{ succeeded: number; failed: number }> { + const catalog = await resolvedOperationClient.list({ pageSize: 100 }); + const op = kind === 'service-restart' ? SERVICE_RESTART : SYSTEM_REBOOT; + const entry = catalog.items.find((item) => item.operationId === op.operationId); + if (!entry || entry.parameterSchemaId !== op.parameterSchemaId) + throw new Error('Operation is not available.'); + let succeeded = 0; + let failed = 0; + let completed = 0; + for (const target of targets) { + try { + const prepared = await resolvedOperationClient.prepare({ + operationId: op.operationId, + targets: [target], + parameters: { + parameterSchemaId: op.parameterSchemaId, + fields: + kind === 'system-reboot' + ? [{ fieldId: 'delay_seconds', kind: 'number', value: SYSTEM_REBOOT.delaySeconds }] + : [], + }, + }); + const confirmed = + typeof window === 'undefined' + ? true + : window.confirm( + `${prepared.confirmationPrompt} +风险等级 ${prepared.risk}。确认继续?`, + ); + if (!confirmed) { + failed += 1; + } else { + const job = await resolvedOperationClient.execute(prepared.id); + if (job.status === 'succeeded') succeeded += 1; + else failed += 1; + } + } catch { + failed += 1; + } + completed += 1; + setBatchProgress({ + action: kind, + total: targets.length, + completed, + succeeded, + failed, + message: `${op.title}进度 ${completed}/${targets.length}`, + }); + } + return { succeeded, failed }; + } + + async function runBatch(kind: BatchActionKind): Promise { + const targets = selectedTargets(); + if (targets.length === 0) { + setBatchProgress({ + action: kind, + total: 0, + completed: 0, + succeeded: 0, + failed: 0, + message: '所选实例缺少配置版本,请刷新后重试。', + }); + return; + } + const title = kind === 'service-restart' ? SERVICE_RESTART.title : SYSTEM_REBOOT.title; + if ( + typeof window !== 'undefined' && + !window.confirm(`将对 ${targets.length} 个实例执行「${title}」。此操作为高风险,确认继续?`) + ) + return; + setBatchBusy(true); + setBatchProgress({ + action: kind, + total: targets.length, + completed: 0, + succeeded: 0, + failed: 0, + message: `正在执行${title}…`, + }); + try { + const result = await runOperation(kind, targets); + setBatchProgress({ + action: kind, + total: targets.length, + completed: targets.length, + succeeded: result.succeeded, + failed: result.failed, + message: `${title}完成:成功 ${result.succeeded},失败 ${result.failed}。`, + }); + } catch (error) { + setBatchProgress({ + action: kind, + total: targets.length, + completed: 0, + succeeded: 0, + failed: targets.length, + message: safeUiError(error, `${title}失败,请稍后重试。`), + }); + } finally { + setBatchBusy(false); + } + } + + async function runCardAction(id: string, kind: BatchActionKind): Promise { + const instance = snapshot?.instances.find((item) => item.id === id); + if (!instance?.revision || instance.revision < 1) { + setCardAction({ id, busy: false, error: '缺少配置版本,请刷新后重试。' }); + return; + } + const title = kind === 'service-restart' ? SERVICE_RESTART.title : SYSTEM_REBOOT.title; + if ( + typeof window !== 'undefined' && + !window.confirm(`将对该实例执行「${title}」。此操作为高风险,确认继续?`) + ) + return; + setCardAction({ id, busy: true, message: `正在${title}…` }); + try { + const result = await runOperation(kind, [{ instanceId: id, revision: instance.revision }]); + setCardAction({ + id, + busy: false, + message: + result.succeeded === 1 + ? `${title}已提交成功。` + : `${title}失败,请稍后重试。`, + ...(result.failed === 1 ? { error: `${title}失败,请稍后重试。` } : {}), + }); + } catch (error) { + setCardAction({ + id, + busy: false, + error: safeUiError(error, `${title}失败,请稍后重试。`), + }); + } + } + const selectFilter = ( label: string, value: string, @@ -362,7 +534,32 @@ export function FleetPage({ ) : null} {batchOpen ? (
- 请为已选择的 {model.selectedIds.length} 项选择操作。 +
+ 批量操作 +

已选择 {model.selectedIds.length} 项。重启将逐个实例安全确认后执行。

+
+
+ + +
+ {batchProgress ? ( +

+ {batchProgress.message} +

+ ) : null}
) : null}
@@ -393,7 +590,7 @@ export function FleetPage({
- 更多筛选与列设置 + 更多筛选
@@ -450,7 +661,7 @@ export function FleetPage({ {!snapshot && !error ? (

- 正在加载实例… + 正在加载实例列表…

) : null} {error ? ( @@ -481,11 +692,19 @@ export function FleetPage({
{model.rows.map((row) => (
+
+ {(() => { + const origin = canonicalHttpOrigin(row.instance.url); + return ( +

+ {origin ?? '源地址无效'} +

+ ); + })()} + {row.anomalies.length > 0 ? ( +

+ {row.anomalies.join(', ')} +

+ ) : null}
+ {cardAction?.id === row.id ? ( +

+ {cardAction.error ?? cardAction.message} +

+ ) : null} {deleteId === row.id ? (

删除后无法撤销。

@@ -650,123 +907,27 @@ export function FleetPage({ ))}
-
-

高级详细列表

-
- - - - - - {ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => ( - - ))} - - - - {model.rows.map((row) => { - const origin = canonicalHttpOrigin(row.instance.url); - const cells: Readonly> = { - name: ( - - ), - status: ( - - ), - latency: , - version: , - capabilities: ( - - ), - tags: , - freshness: , - anomalies: , - origin: , - }; - return ( - - - {ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => ( - {cells[column]} - ))} - - ); - })} - -
实例列表
- - - {column === 'origin' ? ( - COLUMN_LABELS[column] - ) : ( - - )} -
- - {row.displayName} - - {row.id} - - - {STATUS_LABELS[row.statusKind]} - - {row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}{row.version ?? '—'}{row.capabilities.map(capabilityLabel).join(', ') || '—'}{row.tags.join(', ') || '—'}{FRESHNESS_LABELS[row.freshness] ?? row.freshness}{row.anomalies.join(', ') || '—'}{origin ? {origin} : 源地址无效}
- -
-
- -
+ ) : null} diff --git a/apps/web/src/fleet/fleet-table-view-model.ts b/apps/web/src/fleet/fleet-table-view-model.ts index 0a56087..60e82ef 100644 --- a/apps/web/src/fleet/fleet-table-view-model.ts +++ b/apps/web/src/fleet/fleet-table-view-model.ts @@ -19,6 +19,8 @@ export interface FleetInstance { readonly url: string; readonly description?: string; readonly tags?: readonly string[]; + /** Optional config revision for prepare/execute CAS when available from the control plane. */ + readonly revision?: number; } export interface FleetStatus { diff --git a/apps/web/src/instances/overview-system.test.tsx b/apps/web/src/instances/overview-system.test.tsx index 0875661..bf40a84 100644 --- a/apps/web/src/instances/overview-system.test.tsx +++ b/apps/web/src/instances/overview-system.test.tsx @@ -83,8 +83,9 @@ describe('Phase 6.1 Overview / System read slice', () => { } expect(within(screen.getByRole('region', { name: '设备' })).getByText('SIMBox 8')).toBeTruthy(); expect(screen.getByText(/观测时间:2026-07-17T10:00:00Z/)).toBeTruthy(); - expect(screen.queryByRole('button', { name: /restart/i })).toBeNull(); - expect(screen.getByText(/重启不可用.*R3/i)).toBeTruthy(); + expect(screen.getByRole('button', { name: '重启服务' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '系统重启' })).toBeTruthy(); + expect(screen.getByText(/服务重启与系统重启均为高风险操作/i)).toBeTruthy(); }); it('translates only known field names and values while preserving unknown safe data', async () => { @@ -133,7 +134,8 @@ describe('Phase 6.1 Overview / System read slice', () => { expect(within(operations).getByText('46.7 °C')).toBeTruthy(); expect(within(operations).getByText('13800138000')).toBeTruthy(); expect(screen.queryByText(/没有可用的安全概览只读数据源/i)).toBeNull(); - expect(screen.queryByRole('button', { name: /restart/i })).toBeNull(); + expect(screen.getByRole('button', { name: '重启服务' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '系统重启' })).toBeTruthy(); }); it('shows authentication-required without loading or leaking prior owner data', async () => { diff --git a/apps/web/src/instances/overview-system.tsx b/apps/web/src/instances/overview-system.tsx index 55e9c78..f4b023f 100644 --- a/apps/web/src/instances/overview-system.tsx +++ b/apps/web/src/instances/overview-system.tsx @@ -1,6 +1,11 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import type { InstanceContext } from '../app-shell.js'; +import { + createOperationClient, + type OperationClient, +} from '../operations/operation-client.js'; +import { safeUiError } from '../ui/locale.js'; export type OverviewFieldValue = string | number | boolean | null; export type OverviewSection = Readonly>; @@ -25,6 +30,8 @@ export interface OverviewSystemPageProps { readonly dataSource?: OverviewDataSource; /** Change this value when an external owner has requested a refresh. */ readonly refreshSignal?: unknown; + readonly operationClient?: OperationClient; + readonly revision?: number; } type ReadState = @@ -166,10 +173,78 @@ export function OverviewSystemPage({ instance, dataSource, refreshSignal, + operationClient, + revision, }: OverviewSystemPageProps) { const requestOwner = useRef(0); const [retry, setRetry] = useState(0); const [state, setState] = useState({ kind: 'idle' }); + const [actionState, setActionState] = useState<{ + busy: boolean; + message?: string; + error?: string; + }>({ busy: false }); + const client = useMemo(() => operationClient ?? createOperationClient(), [operationClient]); + const effectiveRevision = revision ?? instance.revision; + + async function runOverviewAction(kind: 'service' | 'system'): Promise { + const targetRevision = effectiveRevision; + if (!targetRevision || targetRevision < 1) { + setActionState({ busy: false, error: '缺少配置版本,请刷新后重试。' }); + return; + } + const op = + kind === 'service' + ? { + operationId: 'postServiceRestart', + parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1', + title: '重启服务', + fields: [] as const, + } + : { + operationId: 'postSystemReboot', + parameterSchemaId: 'simadmin.58e2204.postSystemReboot.parameters.v1', + title: '系统重启', + fields: [{ fieldId: 'delay_seconds', kind: 'number' as const, value: 3 }], + }; + if ( + typeof window !== 'undefined' && + !window.confirm(`将执行「${op.title}」。此操作为高风险,确认继续?`) + ) + return; + setActionState({ busy: true, message: `正在${op.title}…` }); + try { + await client.list({ pageSize: 100 }); + const prepared = await client.prepare({ + operationId: op.operationId, + targets: [{ instanceId: instance.id, revision: targetRevision }], + parameters: { + parameterSchemaId: op.parameterSchemaId, + fields: [...op.fields], + }, + }); + const confirmed = + typeof window === 'undefined' + ? true + : window.confirm(`${prepared.confirmationPrompt} +风险等级 ${prepared.risk}。确认继续?`); + if (!confirmed) { + setActionState({ busy: false, message: '已取消操作。' }); + return; + } + const job = await client.execute(prepared.id); + setActionState({ + busy: false, + message: job.status === 'succeeded' ? `${op.title}已提交成功。` : `${op.title}结果未知或失败。`, + ...(job.status === 'succeeded' ? {} : { error: `${op.title}结果未知或失败。` }), + }); + } catch (error) { + setActionState({ + busy: false, + error: safeUiError(error, `${op.title}失败,请稍后重试。`), + }); + } + } useEffect(() => { const request = ++requestOwner.current; @@ -212,37 +287,95 @@ export function OverviewSystemPage({ return () => controller.abort(); }, [dataSource, instance.authentication, instance.id, refreshSignal, retry]); + const systemOperations = ( +
+

系统操作

+

服务重启与系统重启均为高风险操作,执行前会再次确认。

+
+ + +
+ {!effectiveRevision ? ( +

缺少配置版本,暂不可执行重启。请从实例列表进入并刷新后重试。

+ ) : null} + {actionState.error ? ( +

+ {actionState.error} +

+ ) : null} + {actionState.message ? ( +

+ {actionState.message} +

+ ) : null} +
+ ); + if (instance.authentication === 'auth-required') return ( -
- 需要先完成认证,才能读取概览数据。 +
+
+ 需要先完成认证,才能读取概览数据。 +
+ {systemOperations}
); - if (!dataSource) return ; + if (!dataSource) + return ( +
+ +

+ 未提供安全概览只读数据源,但系统操作仍可使用。 +

+ {systemOperations} +
+ ); const retainedSnapshot = state.kind === 'loading' || state.kind === 'error' ? state.snapshot : undefined; + const snapshot = state.kind === 'ready' ? state.snapshot : retainedSnapshot; + if ((state.kind === 'loading' || state.kind === 'idle') && !retainedSnapshot) return ( -

- 正在加载概览… -

+
+ +

+ 正在加载概览… +

+ {systemOperations} +
); if (state.kind === 'error' && !state.snapshot) return ( -
-

无法加载概览: {state.message}

- +
+ +
+

无法加载概览: {state.message}

+ +
+ {systemOperations}
); - const snapshot = state.kind === 'ready' ? state.snapshot : retainedSnapshot; - if (!snapshot) return null; + if (!snapshot) return
{systemOperations}
; return (
@@ -267,10 +400,7 @@ export function OverviewSystemPage({ ))}
-
- 系统操作说明 -

重启不可用,因为后端尚未实现 R3 重启操作,因此不提供任何操作。

-
+ {systemOperations}
); } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 1f13aae..ed60669 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1222,6 +1222,94 @@ main ul[aria-label='已配置实例'] { flex: 0 0 auto; } + +.fleet-card.is-selected { + border-color: color-mix(in srgb, var(--accent, #f2a93b) 55%, var(--border)); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--accent, #f2a93b) 35%, transparent), + var(--shadow); +} +.fleet-card header { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 0.75rem; + align-items: start; +} +.fleet-card-select { + display: grid; + place-items: center; + padding-top: 0.2rem; +} +.fleet-card-select input { + width: 1.1rem; + height: 1.1rem; +} +.fleet-card-metrics { + display: grid; + gap: 0.65rem; + margin: 0.85rem 0 0.5rem; +} +.fleet-card-metrics meter { + width: 100%; + height: 0.55rem; + margin-right: 0.4rem; +} +.fleet-card-admin-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.75rem; +} +.fleet-card-admin-actions button, +.fleet-card-admin-actions a { + min-height: 2.25rem; +} +.batch-entry { + display: grid; + gap: 0.75rem; + padding: 1rem 1.1rem; + border: 1px solid var(--border); + border-radius: 1rem; + background: color-mix(in srgb, var(--surface-raised, #fff) 92%, var(--accent, #f2a93b)); +} +.batch-entry-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.batch-progress { + margin: 0; + font-size: 0.925rem; +} +.fleet-status-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; + margin: 0.75rem 0 1rem; +} +.fleet-status-summary > div { + padding: 0.85rem 1rem; + border: 1px solid var(--border); + border-radius: 0.9rem; + background: var(--surface-raised, #fff); + display: grid; + gap: 0.25rem; +} +.fleet-status-summary strong { + font-size: 1.35rem; +} +@media (max-width: 720px) { + .fleet-status-summary { + grid-template-columns: 1fr; + } + .fleet-card header { + grid-template-columns: auto 1fr; + } + .fleet-card header .status { + grid-column: 2; + } +} + @media (prefers-reduced-motion: reduce) { *, *::before, @@ -1232,3 +1320,28 @@ main ul[aria-label='已配置实例'] { animation-iteration-count: 1 !important; } } + +.fleet-select-all { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: end; +} +.fleet-select-all button { + min-height: 2.5rem; +} +.fleet-card-section + .pagination { + margin-top: 1rem; +} + +.fleet-card-origin { + margin: 0.15rem 0 0; + color: var(--muted, #6b7280); + font-size: 0.85rem; + word-break: break-all; +} +.fleet-card-anomalies { + margin: 0.25rem 0 0; + color: var(--danger, #b45309); + font-size: 0.85rem; +}