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:
@@ -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}',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,22 +18,47 @@ export interface SecureOperationDescriptor {
|
|||||||
export interface SecureOperationRegistry {
|
export interface SecureOperationRegistry {
|
||||||
requireExecutableOperation(operationId: string): SecureOperationDescriptor;
|
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 = {
|
export const secureOperationRegistry: SecureOperationRegistry = {
|
||||||
requireExecutableOperation(operationId) {
|
requireExecutableOperation(operationId) {
|
||||||
if (operationId !== 'postNetworkRegisterAuto') throw new Error('UNKNOWN_OPERATION');
|
const descriptor = EXECUTABLE_OPERATIONS[operationId];
|
||||||
return {
|
if (!descriptor) throw new Error('UNKNOWN_OPERATION');
|
||||||
operationId: 'postNetworkRegisterAuto',
|
return descriptor;
|
||||||
title: 'Register Network Automatically',
|
|
||||||
riskLevel: 'R2',
|
|
||||||
method: 'POST',
|
|
||||||
pathTemplate: '/api/network/register-auto',
|
|
||||||
requestContentType: 'none',
|
|
||||||
parameterSchemaId: 'simadmin.58e2204.postNetworkRegisterAuto.parameters.v1',
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
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 ACTOR = 'loopback-control-plane';
|
||||||
const TTL_MS = 5 * 60 * 1000;
|
const TTL_MS = 5 * 60 * 1000;
|
||||||
const EMPTY_DIGEST = createHash('sha256').update('').digest('hex');
|
const EMPTY_DIGEST = createHash('sha256').update('').digest('hex');
|
||||||
@@ -44,7 +69,8 @@ export interface SafeOperationTransportRequest {
|
|||||||
readonly path: string;
|
readonly path: string;
|
||||||
readonly query: string;
|
readonly query: string;
|
||||||
readonly contentType: 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 {
|
export interface SafeOperationTransport {
|
||||||
request(request: SafeOperationTransportRequest): Promise<{ readonly status: number }>;
|
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 =>
|
const validRevision = (value: unknown): value is number =>
|
||||||
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
||||||
const parameterDigest = (schema: string): string =>
|
const parameterDigest = (schema: string, fields: readonly unknown[] = []): string =>
|
||||||
digest(JSON.stringify({ parameterSchemaId: schema, fields: [] }));
|
digest(JSON.stringify({ parameterSchemaId: schema, fields }));
|
||||||
|
|
||||||
export class SecureOperationExecution {
|
export class SecureOperationExecution {
|
||||||
private readonly clock: () => Date;
|
private readonly clock: () => Date;
|
||||||
@@ -131,11 +157,6 @@ export class SecureOperationExecution {
|
|||||||
typeof input.operationId !== 'string'
|
typeof input.operationId !== 'string'
|
||||||
)
|
)
|
||||||
this.validation();
|
this.validation();
|
||||||
if (input.operationId !== ALLOWED_OPERATION)
|
|
||||||
throw new SecureOperationExecutionError(
|
|
||||||
'OPERATION_NOT_ALLOWED',
|
|
||||||
'Operation is not enabled for generic execution',
|
|
||||||
);
|
|
||||||
let descriptor: SecureOperationDescriptor;
|
let descriptor: SecureOperationDescriptor;
|
||||||
try {
|
try {
|
||||||
descriptor = this.options.registry.requireExecutableOperation(input.operationId);
|
descriptor = this.options.registry.requireExecutableOperation(input.operationId);
|
||||||
@@ -146,10 +167,9 @@ export class SecureOperationExecution {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
descriptor.riskLevel !== 'R2' ||
|
descriptor.operationId !== input.operationId ||
|
||||||
|
(descriptor.riskLevel !== 'R2' && descriptor.riskLevel !== 'R3') ||
|
||||||
descriptor.method !== 'POST' ||
|
descriptor.method !== 'POST' ||
|
||||||
descriptor.pathTemplate !== '/api/network/register-auto' ||
|
|
||||||
descriptor.requestContentType !== 'none' ||
|
|
||||||
!Array.isArray(input.targets) ||
|
!Array.isArray(input.targets) ||
|
||||||
input.targets.length !== 1
|
input.targets.length !== 1
|
||||||
)
|
)
|
||||||
@@ -172,10 +192,34 @@ export class SecureOperationExecution {
|
|||||||
Array.isArray(parameters) ||
|
Array.isArray(parameters) ||
|
||||||
Object.keys(parameters).some((key) => !['parameterSchemaId', 'fields'].includes(key)) ||
|
Object.keys(parameters).some((key) => !['parameterSchemaId', 'fields'].includes(key)) ||
|
||||||
parameters.parameterSchemaId !== descriptor.parameterSchemaId ||
|
parameters.parameterSchemaId !== descriptor.parameterSchemaId ||
|
||||||
!Array.isArray(parameters.fields) ||
|
!Array.isArray(parameters.fields)
|
||||||
parameters.fields.length !== 0
|
|
||||||
)
|
)
|
||||||
this.validation();
|
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
|
const instance = this.options.db
|
||||||
.prepare('SELECT base_url,config_revision FROM instances WHERE id=? AND enabled=1')
|
.prepare('SELECT base_url,config_revision FROM instances WHERE id=? AND enabled=1')
|
||||||
.get(target.instanceId) as { base_url: string; config_revision: number } | undefined;
|
.get(target.instanceId) as { base_url: string; config_revision: number } | undefined;
|
||||||
@@ -216,10 +260,10 @@ export class SecureOperationExecution {
|
|||||||
descriptor.method,
|
descriptor.method,
|
||||||
descriptor.pathTemplate,
|
descriptor.pathTemplate,
|
||||||
'',
|
'',
|
||||||
EMPTY_DIGEST,
|
bodyDigest,
|
||||||
'',
|
contentType,
|
||||||
descriptor.parameterSchemaId,
|
descriptor.parameterSchemaId,
|
||||||
parameterDigest(descriptor.parameterSchemaId),
|
parameterDigest(descriptor.parameterSchemaId, parameters.fields),
|
||||||
this.nonce(),
|
this.nonce(),
|
||||||
digest(token),
|
digest(token),
|
||||||
ACTOR,
|
ACTOR,
|
||||||
@@ -232,7 +276,7 @@ export class SecureOperationExecution {
|
|||||||
id,
|
id,
|
||||||
status: 'prepared',
|
status: 'prepared',
|
||||||
operationId: descriptor.operationId,
|
operationId: descriptor.operationId,
|
||||||
risk: 'R2',
|
risk: descriptor.riskLevel as 'R2' | 'R3',
|
||||||
expiresAt,
|
expiresAt,
|
||||||
confirmationToken: token,
|
confirmationToken: token,
|
||||||
confirmationPrompt: `Execute ${descriptor.title} on ${target.instanceId}?`,
|
confirmationPrompt: `Execute ${descriptor.title} on ${target.instanceId}?`,
|
||||||
@@ -278,16 +322,28 @@ export class SecureOperationExecution {
|
|||||||
const instance = this.options.db
|
const instance = this.options.db
|
||||||
.prepare('SELECT base_url,config_revision FROM instances WHERE id=? AND enabled=1')
|
.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;
|
.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 =
|
const bindingValid =
|
||||||
descriptor.operationId === ALLOWED_OPERATION &&
|
!!EXECUTABLE_OPERATIONS[descriptor.operationId] &&
|
||||||
descriptor.riskLevel === 'R2' &&
|
(descriptor.riskLevel === 'R2' || descriptor.riskLevel === 'R3') &&
|
||||||
|
descriptor.riskLevel === row.risk_level &&
|
||||||
descriptor.method === row.method &&
|
descriptor.method === row.method &&
|
||||||
descriptor.pathTemplate === row.path &&
|
descriptor.pathTemplate === row.path &&
|
||||||
descriptor.parameterSchemaId === row.parameter_schema_id &&
|
descriptor.parameterSchemaId === row.parameter_schema_id &&
|
||||||
row.canonical_query === '' &&
|
row.canonical_query === '' &&
|
||||||
row.content_type === '' &&
|
row.content_type === expectedContentType &&
|
||||||
row.body_digest === EMPTY_DIGEST &&
|
row.body_digest === expectedBodyDigest &&
|
||||||
row.parameters_digest === parameterDigest(row.parameter_schema_id) &&
|
row.parameters_digest === parameterDigest(row.parameter_schema_id, expectedFields) &&
|
||||||
!!row.nonce &&
|
!!row.nonce &&
|
||||||
!!instance &&
|
!!instance &&
|
||||||
instance.base_url === row.target_origin &&
|
instance.base_url === row.target_origin &&
|
||||||
@@ -310,12 +366,13 @@ export class SecureOperationExecution {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO jobs
|
`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)
|
(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(
|
.run(
|
||||||
ids.job,
|
ids.job,
|
||||||
ids.job,
|
ids.job,
|
||||||
row.operation_id,
|
row.operation_id,
|
||||||
|
row.risk_level,
|
||||||
actor,
|
actor,
|
||||||
requestId,
|
requestId,
|
||||||
row.parameters_digest,
|
row.parameters_digest,
|
||||||
@@ -350,8 +407,11 @@ export class SecureOperationExecution {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: bound.path,
|
path: bound.path,
|
||||||
query: '',
|
query: '',
|
||||||
contentType: '',
|
contentType: bound.content_type,
|
||||||
body: undefined,
|
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';
|
state = response.status >= 200 && response.status < 300 ? 'succeeded' : 'failed';
|
||||||
code = state === 'succeeded' ? 'UPSTREAM_SUCCEEDED' : 'UPSTREAM_REJECTED';
|
code = state === 'succeeded' ? 'UPSTREAM_SUCCEEDED' : 'UPSTREAM_REJECTED';
|
||||||
@@ -373,9 +433,9 @@ export class SecureOperationExecution {
|
|||||||
return this.options.db.transaction(() => {
|
return this.options.db.transaction(() => {
|
||||||
const jobs = this.options.db
|
const jobs = this.options.db
|
||||||
.prepare(
|
.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');
|
for (const row of jobs) this.finishByJob(row.id, now, 'unknown-result', 'INTERRUPTED');
|
||||||
return jobs.length;
|
return jobs.length;
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ it('registers the read-only operations catalog without touching upstream', async
|
|||||||
upstreamCalls += 1;
|
upstreamCalls += 1;
|
||||||
throw new Error('unexpected');
|
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');
|
const response = await app.inject('/api/v1/operations?pageSize=1');
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ describe('buildControlPlaneApp', () => {
|
|||||||
get: async () => ({ status: 200, headers: {}, body: '' }),
|
get: async () => ({ status: 200, headers: {}, body: '' }),
|
||||||
request: async () => ({ status: 200, headers: {}, body: '' }),
|
request: async () => ({ status: 200, headers: {}, body: '' }),
|
||||||
postNetworkRegisterAuto: async () => ({ status: 200 }),
|
postNetworkRegisterAuto: async () => ({ status: 200 }),
|
||||||
|
postServiceRestart: async () => ({ status: 200 }),
|
||||||
|
postSystemReboot: async () => ({ status: 200 }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,6 +66,8 @@ describe('buildControlPlaneApp', () => {
|
|||||||
get: async () => ({ status: 200, headers: {}, body: '' }),
|
get: async () => ({ status: 200, headers: {}, body: '' }),
|
||||||
request: async () => ({ status: 200, headers: {}, body: '' }),
|
request: async () => ({ status: 200, headers: {}, body: '' }),
|
||||||
postNetworkRegisterAuto: async () => ({ status: 200 }),
|
postNetworkRegisterAuto: async () => ({ status: 200 }),
|
||||||
|
postServiceRestart: async () => ({ status: 200 }),
|
||||||
|
postSystemReboot: async () => ({ status: 200 }),
|
||||||
},
|
},
|
||||||
authenticateEventStream: (request) => request.headers.authorization === 'Bearer allowed',
|
authenticateEventStream: (request) => request.headers.authorization === 'Bearer allowed',
|
||||||
});
|
});
|
||||||
@@ -150,6 +154,8 @@ describe('buildControlPlaneApp', () => {
|
|||||||
},
|
},
|
||||||
request: async () => ({ status: 200, headers: {}, body: '' }),
|
request: async () => ({ status: 200, headers: {}, body: '' }),
|
||||||
postNetworkRegisterAuto: async () => ({ status: 200 }),
|
postNetworkRegisterAuto: async () => ({ status: 200 }),
|
||||||
|
postServiceRestart: async () => ({ status: 200 }),
|
||||||
|
postSystemReboot: async () => ({ status: 200 }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const created = await app.inject({
|
const created = await app.inject({
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import { InstanceMessageService } from './application/messages/instance-message-
|
|||||||
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
||||||
request: UpstreamSessionClientOptions['request'];
|
request: UpstreamSessionClientOptions['request'];
|
||||||
postNetworkRegisterAuto(origin: string): Promise<{ readonly status: number }>;
|
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 {
|
export interface ControlPlaneOptions {
|
||||||
readonly db: Database.Database;
|
readonly db: Database.Database;
|
||||||
@@ -87,9 +89,22 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
|||||||
db: options.db,
|
db: options.db,
|
||||||
registry: secureOperationRegistry,
|
registry: secureOperationRegistry,
|
||||||
transport: {
|
transport: {
|
||||||
request: async ({ origin }) => {
|
request: async ({ origin, path, body, contentType }) => {
|
||||||
const response = await options.upstream.postNetworkRegisterAuto(origin);
|
if (path === '/api/network/register-auto') {
|
||||||
return { status: response.status };
|
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 } : {}),
|
...(options.now ? { now: options.now } : {}),
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ describe('createSafeControlPlaneUpstream', () => {
|
|||||||
body: '[REDACTED]',
|
body: '[REDACTED]',
|
||||||
});
|
});
|
||||||
await upstream.postNetworkRegisterAuto('http://192.168.1.20');
|
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([
|
expect(calls).toEqual([
|
||||||
{ method: 'GET', url: 'http://192.168.1.20/api/health' },
|
{ method: 'GET', url: 'http://192.168.1.20/api/health' },
|
||||||
{
|
{
|
||||||
@@ -37,6 +39,18 @@ describe('createSafeControlPlaneUpstream', () => {
|
|||||||
headers: {},
|
headers: {},
|
||||||
body: '',
|
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}',
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { SafeUpstreamGateway, type SafeUpstreamTransport } from './safe-upstream
|
|||||||
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
||||||
request: UpstreamSessionClientOptions['request'];
|
request: UpstreamSessionClientOptions['request'];
|
||||||
postNetworkRegisterAuto(origin: string): Promise<{ readonly status: number }>;
|
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(
|
export function createSafeControlPlaneUpstream(
|
||||||
transport: SafeUpstreamTransport,
|
transport: SafeUpstreamTransport,
|
||||||
@@ -14,5 +16,7 @@ export function createSafeControlPlaneUpstream(
|
|||||||
get: (url) => transport.get(url),
|
get: (url) => transport.get(url),
|
||||||
request: (request) => gateway.request(request),
|
request: (request) => gateway.request(request),
|
||||||
postNetworkRegisterAuto: (origin) => gateway.postNetworkRegisterAuto(origin),
|
postNetworkRegisterAuto: (origin) => gateway.postNetworkRegisterAuto(origin),
|
||||||
|
postServiceRestart: (origin) => gateway.postServiceRestart(origin),
|
||||||
|
postSystemReboot: (origin, delaySeconds) => gateway.postSystemReboot(origin, delaySeconds),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,4 +161,27 @@ describe('SafeUpstreamGateway', () => {
|
|||||||
}),
|
}),
|
||||||
).rejects.toThrow('UPSTREAM_REQUEST_INVALID');
|
).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}',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export interface SafeUpstreamTransport {
|
|||||||
}
|
}
|
||||||
export class SafeUpstreamGateway {
|
export class SafeUpstreamGateway {
|
||||||
constructor(private readonly options: { readonly transport: SafeUpstreamTransport }) {}
|
constructor(private readonly options: { readonly transport: SafeUpstreamTransport }) {}
|
||||||
async postNetworkRegisterAuto(origin: string): Promise<UpstreamResponse> {
|
private assertOrigin(origin: string): string {
|
||||||
let parsed: URL;
|
let parsed: URL;
|
||||||
try {
|
try {
|
||||||
parsed = new URL(origin);
|
parsed = new URL(origin);
|
||||||
@@ -32,7 +32,12 @@ export class SafeUpstreamGateway {
|
|||||||
parsed.hash
|
parsed.hash
|
||||||
)
|
)
|
||||||
throw this.notDispatched();
|
throw this.notDispatched();
|
||||||
const url = `${parsed.origin}/api/network/register-auto`;
|
return parsed.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async postZeroBody(origin: string, path: string): Promise<UpstreamResponse> {
|
||||||
|
const base = this.assertOrigin(origin);
|
||||||
|
const url = `${base}${path}`;
|
||||||
try {
|
try {
|
||||||
return await this.options.transport.post(url, {}, '');
|
return await this.options.transport.post(url, {}, '');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -44,6 +49,34 @@ export class SafeUpstreamGateway {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async postNetworkRegisterAuto(origin: string): Promise<UpstreamResponse> {
|
||||||
|
return this.postZeroBody(origin, '/api/network/register-auto');
|
||||||
|
}
|
||||||
|
|
||||||
|
async postServiceRestart(origin: string): Promise<UpstreamResponse> {
|
||||||
|
return this.postZeroBody(origin, '/api/service/restart');
|
||||||
|
}
|
||||||
|
|
||||||
|
async postSystemReboot(origin: string, delaySeconds: number): Promise<UpstreamResponse> {
|
||||||
|
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<UpstreamResponse> {
|
async request(request: UpstreamRequest): Promise<UpstreamResponse> {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const headerKeys = Object.keys(request.headers).sort().join(',');
|
const headerKeys = Object.keys(request.headers).sort().join(',');
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ async function fixtureOptions(): Promise<ProductionControlPlaneOptions> {
|
|||||||
get: async () => ({ status: 200, headers: {}, body: '' }),
|
get: async () => ({ status: 200, headers: {}, body: '' }),
|
||||||
request: async () => ({ status: 200, headers: {}, body: '' }),
|
request: async () => ({ status: 200, headers: {}, body: '' }),
|
||||||
postNetworkRegisterAuto: async () => ({ status: 200 }),
|
postNetworkRegisterAuto: async () => ({ status: 200 }),
|
||||||
|
postServiceRestart: async () => ({ status: 200 }),
|
||||||
|
postSystemReboot: async () => ({ status: 200 }),
|
||||||
},
|
},
|
||||||
keychainMetadataCheck: async () => true,
|
keychainMetadataCheck: async () => true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -82,12 +82,14 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
);
|
);
|
||||||
resolve(snapshot);
|
resolve(snapshot);
|
||||||
|
|
||||||
const alpha = await screen.findByRole('row', { name: /Alpha/ });
|
const alpha = await screen.findByRole('article', { name: /Alpha 实例概览/ });
|
||||||
expect(within(alpha).getByRole('link', { name: 'Alpha' }).getAttribute('href')).toBe(
|
|
||||||
'/instances/alpha/overview',
|
|
||||||
);
|
|
||||||
expect(
|
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();
|
).toBeTruthy();
|
||||||
expect(screen.getByText('版本 0.1.0')).toBeTruthy();
|
expect(screen.getByText('版本 0.1.0')).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
@@ -280,18 +282,18 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
it('supports accessible search, status filtering, sorting, and visible selection', async () => {
|
it('supports accessible search, status filtering, sorting, and visible selection', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AppShell pathname="/fleet" fleetDataSource={source(async () => snapshot)} />);
|
render(<AppShell pathname="/fleet" fleetDataSource={source(async () => snapshot)} />);
|
||||||
await screen.findByRole('row', { name: /Alpha/ });
|
await screen.findByRole('article', { name: /Alpha 实例概览/ });
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /按延迟排序/i }));
|
await user.selectOptions(screen.getByRole('combobox', { name: '排序' }), 'latency:asc');
|
||||||
let rows = screen.getAllByRole('row').slice(1);
|
let cards = screen.getAllByRole('article');
|
||||||
expect(rows[0]?.textContent).toContain('Alpha');
|
expect(cards[0]?.textContent).toContain('Alpha');
|
||||||
await user.click(screen.getByRole('button', { name: /按延迟排序/i }));
|
await user.selectOptions(screen.getByRole('combobox', { name: '排序' }), 'latency:desc');
|
||||||
rows = screen.getAllByRole('row').slice(1);
|
cards = screen.getAllByRole('article');
|
||||||
expect(rows[0]?.textContent).toContain('Bravo');
|
expect(cards[0]?.textContent).toContain('Bravo');
|
||||||
|
|
||||||
await user.selectOptions(screen.getByRole('combobox', { name: '状态' }), 'auth');
|
await user.selectOptions(screen.getByRole('combobox', { name: '状态' }), 'auth');
|
||||||
expect(screen.queryByRole('row', { name: /Alpha/ })).toBeNull();
|
expect(screen.queryByRole('article', { name: /Alpha 实例概览/ })).toBeNull();
|
||||||
await user.click(screen.getByRole('checkbox', { name: '选择当前页全部实例' }));
|
await user.click(screen.getByRole('button', { name: '全选本页' }));
|
||||||
expect((screen.getByRole('checkbox', { name: '选择 Bravo' }) as HTMLInputElement).checked).toBe(
|
expect((screen.getByRole('checkbox', { name: '选择 Bravo' }) as HTMLInputElement).checked).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -314,7 +316,7 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
|
|
||||||
expect((await screen.findByRole('alert')).textContent).toContain('实例加载失败');
|
expect((await screen.findByRole('alert')).textContent).toContain('实例加载失败');
|
||||||
await user.click(screen.getByRole('button', { name: '重试加载实例' }));
|
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();
|
expect(screen.queryByRole('link', { name: '打开 Unsafe 的源站' })).toBeNull();
|
||||||
first.unmount();
|
first.unmount();
|
||||||
|
|
||||||
@@ -328,28 +330,30 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
unmount();
|
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();
|
const user = userEvent.setup();
|
||||||
render(<AppShell pathname="/fleet" fleetDataSource={source(async () => snapshot)} />);
|
render(<AppShell pathname="/fleet" fleetDataSource={source(async () => snapshot)} />);
|
||||||
await screen.findByRole('row', { name: /Alpha/ });
|
await screen.findByRole('article', { name: /Alpha 实例概览/ });
|
||||||
|
|
||||||
await user.selectOptions(screen.getByRole('combobox', { name: '能力' }), 'sms');
|
await user.selectOptions(screen.getByRole('combobox', { name: '能力' }), 'sms');
|
||||||
expect(screen.queryByRole('row', { name: /Alpha/ })).toBeNull();
|
expect(screen.queryByRole('article', { name: /Alpha 实例概览/ })).toBeNull();
|
||||||
expect(screen.getByRole('row', { name: /Bravo/ }).textContent).toContain('clock drift');
|
expect(screen.getByRole('article', { name: /Bravo 实例概览/ }).textContent).toContain(
|
||||||
|
'clock drift',
|
||||||
await user.click(screen.getByRole('button', { name: '选择列' }));
|
);
|
||||||
await user.click(screen.getByRole('checkbox', { name: '显示异常列' }));
|
expect(screen.queryByRole('heading', { name: '高级详细列表' })).toBeNull();
|
||||||
expect(screen.queryByRole('columnheader', { name: '异常' })).toBeNull();
|
expect(screen.queryByRole('table')).toBeNull();
|
||||||
|
|
||||||
await user.click(screen.getByRole('checkbox', { name: '选择 Bravo' }));
|
await user.click(screen.getByRole('checkbox', { name: '选择 Bravo' }));
|
||||||
expect((screen.getByRole('button', { name: '批量操作' }) as HTMLButtonElement).disabled).toBe(
|
expect((screen.getByRole('button', { name: '批量操作' }) as HTMLButtonElement).disabled).toBe(
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
await user.click(screen.getByRole('button', { name: '批量操作' }));
|
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 = {
|
const many: FleetSnapshot = {
|
||||||
instances: Array.from({ length: 11 }, (_, index) => ({
|
instances: Array.from({ length: 11 }, (_, index) => ({
|
||||||
id: `instance-${String(index + 1).padStart(2, '0')}`,
|
id: `instance-${String(index + 1).padStart(2, '0')}`,
|
||||||
@@ -359,14 +363,14 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
statuses: new Map(),
|
statuses: new Map(),
|
||||||
};
|
};
|
||||||
render(<AppShell pathname="/fleet" fleetDataSource={source(async () => many)} />);
|
render(<AppShell pathname="/fleet" fleetDataSource={source(async () => 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();
|
expect(screen.getByText('第 1 页,共 2 页')).toBeTruthy();
|
||||||
fireEvent.click(screen.getByRole('checkbox', { name: '选择当前页全部实例' }));
|
fireEvent.click(screen.getByRole('button', { name: '全选本页' }));
|
||||||
expect(screen.getByText('已选择 10 项')).toBeTruthy();
|
expect(screen.getByText('已选择 10 项')).toBeTruthy();
|
||||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
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();
|
expect(screen.getByText('第 2 页,共 2 页')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export interface InstanceContext {
|
|||||||
status: 'online' | 'offline' | 'auth-required' | 'degraded' | 'unknown';
|
status: 'online' | 'offline' | 'auth-required' | 'degraded' | 'unknown';
|
||||||
authentication: 'authenticated' | 'auth-required' | 'unknown';
|
authentication: 'authenticated' | 'auth-required' | 'unknown';
|
||||||
freshness: 'fresh' | 'stale' | 'expired' | 'unknown';
|
freshness: 'fresh' | 'stale' | 'expired' | 'unknown';
|
||||||
|
/** Control-plane config revision for prepare/execute CAS when known. */
|
||||||
|
revision?: number;
|
||||||
resources?: Readonly<{
|
resources?: Readonly<{
|
||||||
cpuPercent?: number;
|
cpuPercent?: number;
|
||||||
memoryPercent?: number;
|
memoryPercent?: number;
|
||||||
@@ -283,6 +285,7 @@ function Page({
|
|||||||
instance={instance}
|
instance={instance}
|
||||||
{...(overviewDataSource ? { dataSource: overviewDataSource } : {})}
|
{...(overviewDataSource ? { dataSource: overviewDataSource } : {})}
|
||||||
refreshSignal={detailRefreshSignal}
|
refreshSignal={detailRefreshSignal}
|
||||||
|
{...(instance.revision !== undefined ? { revision: instance.revision } : {})}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -450,10 +453,51 @@ export function AppShell({
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let active = true;
|
let active = true;
|
||||||
setInstanceLoading(true);
|
setInstanceLoading(true);
|
||||||
const fleetLoad = (fleetDataSource ?? defaultFleetDataSource).load(controller.signal).then(
|
const fleetLoad = (fleetDataSource ?? defaultFleetDataSource)
|
||||||
(fleet) => ({ ok: true as const, fleet }),
|
.load(controller.signal, (partial) => {
|
||||||
() => ({ ok: false as const }),
|
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(
|
void resolvedInstanceDataSource.get(routeInstanceId).then(
|
||||||
(owner) => {
|
(owner) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
@@ -469,6 +513,7 @@ export function AppShell({
|
|||||||
status: 'unknown',
|
status: 'unknown',
|
||||||
authentication: 'unknown',
|
authentication: 'unknown',
|
||||||
freshness: 'unknown',
|
freshness: 'unknown',
|
||||||
|
...(owner.revision === undefined ? {} : { revision: owner.revision }),
|
||||||
resourceSummaryState: 'loading',
|
resourceSummaryState: 'loading',
|
||||||
});
|
});
|
||||||
setInstanceLoading(false);
|
setInstanceLoading(false);
|
||||||
@@ -494,6 +539,7 @@ export function AppShell({
|
|||||||
? 'auth-required'
|
? 'auth-required'
|
||||||
: 'online';
|
: 'online';
|
||||||
const freshness = status?.summary?.freshness;
|
const freshness = status?.summary?.freshness;
|
||||||
|
const nextRevision = fleetOwner?.revision ?? owner.revision;
|
||||||
setLoadedInstance({
|
setLoadedInstance({
|
||||||
id: owner.id,
|
id: owner.id,
|
||||||
name: owner.name,
|
name: owner.name,
|
||||||
@@ -509,6 +555,7 @@ export function AppShell({
|
|||||||
freshness === 'fresh' || freshness === 'stale' || freshness === 'expired'
|
freshness === 'fresh' || freshness === 'stale' || freshness === 'expired'
|
||||||
? freshness
|
? freshness
|
||||||
: 'unknown',
|
: 'unknown',
|
||||||
|
...(nextRevision === undefined ? {} : { revision: nextRevision }),
|
||||||
...(status?.summary?.resources ? { resources: status.summary.resources } : {}),
|
...(status?.summary?.resources ? { resources: status.summary.resources } : {}),
|
||||||
resourceSummaryState: status ? 'ready' : 'unavailable',
|
resourceSummaryState: status ? 'ready' : 'unavailable',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ describe('Fleet API data source', () => {
|
|||||||
name: 'Alpha',
|
name: 'Alpha',
|
||||||
origin: 'https://alpha.example/admin',
|
origin: 'https://alpha.example/admin',
|
||||||
tags: ['lab'],
|
tags: ['lab'],
|
||||||
revision: 1,
|
revision: 4,
|
||||||
capabilityStatus: 'unknown',
|
capabilityStatus: 'unknown',
|
||||||
freshness: 'unknown',
|
freshness: 'unknown',
|
||||||
credentialConfigured: false,
|
credentialConfigured: false,
|
||||||
@@ -36,7 +36,7 @@ describe('Fleet API data source', () => {
|
|||||||
);
|
);
|
||||||
const snapshot = await createFleetApiDataSource(fetcher as typeof fetch).load();
|
const snapshot = await createFleetApiDataSource(fetcher as typeof fetch).load();
|
||||||
expect(snapshot.instances).toEqual([
|
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({
|
expect(snapshot.statuses.get('alpha')?.summary?.resources).toEqual({
|
||||||
cpuPercent: 23.4,
|
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<Response>((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 () => {
|
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 }));
|
const fetcher = vi.fn(async () => new Response(JSON.stringify({ data: [] }), { status: 200 }));
|
||||||
await expect(createFleetApiDataSource(fetcher as typeof fetch).load()).rejects.toThrow(
|
await expect(createFleetApiDataSource(fetcher as typeof fetch).load()).rejects.toThrow(
|
||||||
|
|||||||
@@ -1,88 +1,142 @@
|
|||||||
import type { FleetDataSource, FleetSnapshot } from './fleet-page.js';
|
import type { FleetDataSource, FleetSnapshot } from './fleet-page.js';
|
||||||
import type { FleetInstance, FleetStatus } from './fleet-table-view-model.js';
|
import type { FleetInstance, FleetStatus } from './fleet-table-view-model.js';
|
||||||
|
|
||||||
interface InstancePage {
|
const integer = (value: unknown): value is number =>
|
||||||
readonly items?: readonly unknown[];
|
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<string, unknown> | undefined =>
|
||||||
|
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
function parseInstance(value: unknown): FleetInstance {
|
function parseInstance(value: unknown): FleetInstance | undefined {
|
||||||
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
const item = record(value);
|
||||||
throw new Error('Fleet response is invalid.');
|
|
||||||
const item = value as Record<string, unknown>;
|
|
||||||
if (
|
if (
|
||||||
typeof item.id !== 'string' ||
|
!item ||
|
||||||
typeof item.name !== 'string' ||
|
!string(item.id) ||
|
||||||
typeof item.origin !== 'string' ||
|
!string(item.name) ||
|
||||||
|
!string(item.origin) ||
|
||||||
!Array.isArray(item.tags) ||
|
!Array.isArray(item.tags) ||
|
||||||
!item.tags.every((tag) => typeof tag === 'string')
|
!item.tags.every((tag) => typeof tag === 'string')
|
||||||
)
|
)
|
||||||
throw new Error('Fleet response is invalid.');
|
return undefined;
|
||||||
return { id: item.id, name: item.name, url: item.origin, tags: item.tags };
|
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<NonNullable<FleetStatus['summary']>['resources']>;
|
function parseResources(value: unknown): NonNullable<FleetStatus['summary']> {
|
||||||
function parseResources(value: unknown): Resources {
|
const body = record(value) ?? {};
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
const resources: {
|
||||||
const item = value as Record<string, unknown>;
|
cpuPercent?: number;
|
||||||
const number = (field: string): number | undefined => {
|
memoryPercent?: number;
|
||||||
const fieldValue = item[field];
|
maxTemperatureCelsius?: number;
|
||||||
return typeof fieldValue === 'number' && Number.isFinite(fieldValue) ? fieldValue : undefined;
|
phoneNumbers?: string[];
|
||||||
};
|
} = {};
|
||||||
const cpuPercent = number('cpuPercent');
|
if (finite(body.cpuPercent)) resources.cpuPercent = body.cpuPercent;
|
||||||
const memoryPercent = number('memoryPercent');
|
if (finite(body.memoryPercent)) resources.memoryPercent = body.memoryPercent;
|
||||||
const maxTemperatureCelsius = number('maxTemperatureCelsius');
|
if (finite(body.maxTemperatureCelsius))
|
||||||
const phoneNumbers = Array.isArray(item.phoneNumbers)
|
resources.maxTemperatureCelsius = body.maxTemperatureCelsius;
|
||||||
? item.phoneNumbers.filter((phone): phone is string => typeof phone === 'string')
|
if (
|
||||||
: undefined;
|
Array.isArray(body.phoneNumbers) &&
|
||||||
|
body.phoneNumbers.every((item) => typeof item === 'string' && item.length > 0)
|
||||||
|
)
|
||||||
|
resources.phoneNumbers = body.phoneNumbers as string[];
|
||||||
return {
|
return {
|
||||||
...(cpuPercent === undefined ? {} : { cpuPercent }),
|
freshness: 'fresh',
|
||||||
...(memoryPercent === undefined ? {} : { memoryPercent }),
|
...(Object.keys(resources).length > 0 ? { resources } : {}),
|
||||||
...(maxTemperatureCelsius === undefined ? {} : { maxTemperatureCelsius }),
|
|
||||||
...(phoneNumbers?.length ? { phoneNumbers } : {}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readJson(response: Response): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
return await response.json();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function skeletonStatuses(instances: readonly FleetInstance[]): Map<string, FleetStatus> {
|
||||||
|
return new Map(
|
||||||
|
instances.map((instance) => [
|
||||||
|
instance.id,
|
||||||
|
{
|
||||||
|
reachable: true,
|
||||||
|
authenticated: true,
|
||||||
|
summary: { freshness: 'unknown' },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDataSource {
|
export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDataSource {
|
||||||
return {
|
return {
|
||||||
async load(signal): Promise<FleetSnapshot> {
|
async load(signal, onPartial) {
|
||||||
const response = await fetcher('/api/v1/instances', {
|
const listResponse = await fetcher('/api/v1/instances', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { accept: 'application/json' },
|
headers: { accept: 'application/json' },
|
||||||
...(signal ? { signal } : {}),
|
...(signal ? { signal } : {}),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`Fleet request failed (${response.status}).`);
|
const listBody = record(await readJson(listResponse));
|
||||||
const body = (await response.json()) as InstancePage;
|
if (
|
||||||
if (!Array.isArray(body.items)) throw new Error('Fleet response is invalid.');
|
!listResponse.ok ||
|
||||||
const instances = body.items.map(parseInstance);
|
!listBody ||
|
||||||
const entries = await Promise.all(
|
!Array.isArray(listBody.items) ||
|
||||||
instances.map(async (instance): Promise<readonly [string, FleetStatus]> => {
|
!record(listBody.page)
|
||||||
try {
|
)
|
||||||
const resourceResponse = await fetcher(
|
throw new Error('Fleet response is invalid.');
|
||||||
`/api/v1/instances/${encodeURIComponent(instance.id)}/resources`,
|
const instances = listBody.items.map(parseInstance);
|
||||||
{
|
if (instances.some((item) => !item)) throw new Error('Fleet response is invalid.');
|
||||||
method: 'GET',
|
const readyInstances = instances as FleetInstance[];
|
||||||
credentials: 'same-origin',
|
const partial: FleetSnapshot = {
|
||||||
headers: { accept: 'application/json' },
|
instances: readyInstances,
|
||||||
...(signal ? { signal } : {}),
|
statuses: skeletonStatuses(readyInstances),
|
||||||
},
|
};
|
||||||
);
|
onPartial?.(partial);
|
||||||
if (!resourceResponse.ok)
|
|
||||||
return [instance.id, { reachable: false, summary: { resources: {} } }];
|
const statuses = new Map(
|
||||||
return [
|
await Promise.all(
|
||||||
instance.id,
|
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,
|
reachable: true,
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
summary: { resources: parseResources(await resourceResponse.json()) },
|
summary: parseResources(await readJson(response)),
|
||||||
},
|
};
|
||||||
];
|
return [instance.id, status] as const;
|
||||||
} catch {
|
} catch {
|
||||||
return [instance.id, { reachable: false, summary: { resources: {} } }];
|
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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { FleetPage, type FleetSnapshot } from './fleet-page.js';
|
import { FleetPage, type FleetSnapshot } from './fleet-page.js';
|
||||||
|
|
||||||
@@ -11,13 +11,13 @@ const snapshot: FleetSnapshot = {
|
|||||||
name: 'Alpha modem',
|
name: 'Alpha modem',
|
||||||
url: 'http://192.168.1.2',
|
url: 'http://192.168.1.2',
|
||||||
tags: [],
|
tags: [],
|
||||||
|
revision: 3,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
statuses: new Map([
|
statuses: new Map([
|
||||||
[
|
[
|
||||||
'alpha',
|
'alpha',
|
||||||
{
|
{
|
||||||
instanceId: 'alpha',
|
|
||||||
reachable: true,
|
reachable: true,
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
summary: { freshness: 'fresh', resources: { cpuPercent: 24, memoryPercent: 51 } },
|
summary: { freshness: 'fresh', resources: { cpuPercent: 24, memoryPercent: 51 } },
|
||||||
@@ -52,3 +52,65 @@ describe('FleetPage card navigation', () => {
|
|||||||
expect(within(card).getByRole('group', { name: '实例管理操作' })).toBeTruthy();
|
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(
|
||||||
|
<FleetPage
|
||||||
|
initialData={snapshot}
|
||||||
|
operationClient={{ list, prepare, execute } as never}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+344
-183
@@ -1,4 +1,4 @@
|
|||||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildFleetTableViewModel,
|
buildFleetTableViewModel,
|
||||||
@@ -15,13 +15,21 @@ import type {
|
|||||||
FleetMessagesDataSource,
|
FleetMessagesDataSource,
|
||||||
} from './fleet-messages-api-data-source.js';
|
} from './fleet-messages-api-data-source.js';
|
||||||
import { loadFleetMessageSummaries } 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 {
|
export interface FleetSnapshot {
|
||||||
readonly instances: readonly FleetInstance[];
|
readonly instances: readonly FleetInstance[];
|
||||||
readonly statuses: ReadonlyMap<string, FleetStatus>;
|
readonly statuses: ReadonlyMap<string, FleetStatus>;
|
||||||
}
|
}
|
||||||
export interface FleetDataSource {
|
export interface FleetDataSource {
|
||||||
load(signal?: AbortSignal): Promise<FleetSnapshot>;
|
load(
|
||||||
|
signal?: AbortSignal,
|
||||||
|
onPartial?: (snapshot: FleetSnapshot) => void,
|
||||||
|
): Promise<FleetSnapshot>;
|
||||||
}
|
}
|
||||||
export interface FleetPageProps {
|
export interface FleetPageProps {
|
||||||
readonly dataSource?: FleetDataSource;
|
readonly dataSource?: FleetDataSource;
|
||||||
@@ -29,6 +37,7 @@ export interface FleetPageProps {
|
|||||||
readonly instanceDataSource?: InstanceDataSource;
|
readonly instanceDataSource?: InstanceDataSource;
|
||||||
readonly initialData?: FleetSnapshot;
|
readonly initialData?: FleetSnapshot;
|
||||||
readonly refreshSignal?: number;
|
readonly refreshSignal?: number;
|
||||||
|
readonly operationClient?: OperationClient;
|
||||||
}
|
}
|
||||||
type FleetMessageState = FleetMessageLoadState;
|
type FleetMessageState = FleetMessageLoadState;
|
||||||
|
|
||||||
@@ -39,7 +48,7 @@ const STATUS_LABELS: Readonly<Record<string, string>> = {
|
|||||||
offline: '离线',
|
offline: '离线',
|
||||||
unknown: '未知',
|
unknown: '未知',
|
||||||
};
|
};
|
||||||
const COLUMN_LABELS: Readonly<Record<FleetSortColumn | 'origin', string>> = {
|
const SORT_COLUMN_LABELS: Readonly<Record<FleetSortColumn, string>> = {
|
||||||
name: '名称',
|
name: '名称',
|
||||||
status: '状态',
|
status: '状态',
|
||||||
latency: '延迟',
|
latency: '延迟',
|
||||||
@@ -48,9 +57,7 @@ const COLUMN_LABELS: Readonly<Record<FleetSortColumn | 'origin', string>> = {
|
|||||||
tags: '标签',
|
tags: '标签',
|
||||||
freshness: '数据新鲜度',
|
freshness: '数据新鲜度',
|
||||||
anomalies: '异常',
|
anomalies: '异常',
|
||||||
origin: '源地址',
|
|
||||||
};
|
};
|
||||||
const ALL_COLUMNS = Object.keys(COLUMN_LABELS) as readonly (FleetSortColumn | 'origin')[];
|
|
||||||
const CAPABILITY_LABELS: Readonly<Record<string, string>> = {
|
const CAPABILITY_LABELS: Readonly<Record<string, string>> = {
|
||||||
overview: '概览',
|
overview: '概览',
|
||||||
cellular: '蜂窝网络',
|
cellular: '蜂窝网络',
|
||||||
@@ -62,11 +69,6 @@ const CAPABILITY_LABELS: Readonly<Record<string, string>> = {
|
|||||||
automation: '自动化',
|
automation: '自动化',
|
||||||
ota: 'OTA',
|
ota: 'OTA',
|
||||||
};
|
};
|
||||||
const FRESHNESS_LABELS: Readonly<Record<string, string>> = {
|
|
||||||
fresh: '最新',
|
|
||||||
stale: '可能过期',
|
|
||||||
unknown: '未知',
|
|
||||||
};
|
|
||||||
|
|
||||||
function capabilityLabel(value: string): string {
|
function capabilityLabel(value: string): string {
|
||||||
return CAPABILITY_LABELS[value] ?? value;
|
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({
|
export function FleetPage({
|
||||||
dataSource,
|
dataSource,
|
||||||
messagesDataSource,
|
messagesDataSource,
|
||||||
instanceDataSource,
|
instanceDataSource,
|
||||||
initialData,
|
initialData,
|
||||||
refreshSignal = 0,
|
refreshSignal = 0,
|
||||||
|
operationClient,
|
||||||
}: FleetPageProps) {
|
}: FleetPageProps) {
|
||||||
const [snapshot, setSnapshot] = useState<FleetSnapshot | null>(initialData ?? null);
|
const [snapshot, setSnapshot] = useState<FleetSnapshot | null>(initialData ?? null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -122,9 +147,15 @@ export function FleetPage({
|
|||||||
direction: 'asc',
|
direction: 'asc',
|
||||||
});
|
});
|
||||||
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
|
||||||
const [shownColumns, setShownColumns] = useState<ReadonlySet<string>>(new Set(ALL_COLUMNS));
|
|
||||||
const [columnsOpen, setColumnsOpen] = useState(false);
|
|
||||||
const [batchOpen, setBatchOpen] = useState(false);
|
const [batchOpen, setBatchOpen] = useState(false);
|
||||||
|
const [batchBusy, setBatchBusy] = useState(false);
|
||||||
|
const [batchProgress, setBatchProgress] = useState<BatchProgress | null>(null);
|
||||||
|
const [cardAction, setCardAction] = useState<{
|
||||||
|
id: string;
|
||||||
|
busy: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
} | null>(null);
|
||||||
const [deleteId, setDeleteId] = useState<string>();
|
const [deleteId, setDeleteId] = useState<string>();
|
||||||
const [deleteOwner, setDeleteOwner] = useState<Readonly<{ id: string; revision: number }>>();
|
const [deleteOwner, setDeleteOwner] = useState<Readonly<{ id: string; revision: number }>>();
|
||||||
const [deleteConfirmation, setDeleteConfirmation] = useState('');
|
const [deleteConfirmation, setDeleteConfirmation] = useState('');
|
||||||
@@ -132,11 +163,14 @@ export function FleetPage({
|
|||||||
const deleteRequestRef = useRef(0);
|
const deleteRequestRef = useRef(0);
|
||||||
const [deleteStatus, setDeleteStatus] =
|
const [deleteStatus, setDeleteStatus] =
|
||||||
useState<Readonly<{ id: string; kind: 'success' | 'error'; message: string }>>();
|
useState<Readonly<{ id: string; kind: 'success' | 'error'; message: string }>>();
|
||||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
|
||||||
const messagesOwner = useRef(0);
|
const messagesOwner = useRef(0);
|
||||||
const [messageStates, setMessageStates] = useState<ReadonlyMap<string, FleetMessageState>>(
|
const [messageStates, setMessageStates] = useState<ReadonlyMap<string, FleetMessageState>>(
|
||||||
new Map(),
|
new Map(),
|
||||||
);
|
);
|
||||||
|
const resolvedOperationClient = useMemo(
|
||||||
|
() => operationClient ?? createOperationClient(),
|
||||||
|
[operationClient],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialData && refreshSignal === 0) {
|
if (initialData && refreshSignal === 0) {
|
||||||
@@ -149,18 +183,27 @@ export function FleetPage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let active = true;
|
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);
|
setError(null);
|
||||||
void dataSource.load().then(
|
void dataSource
|
||||||
(data) => {
|
.load(controller.signal, (partial) => {
|
||||||
if (active) setSnapshot(data);
|
if (active) setSnapshot(partial);
|
||||||
},
|
})
|
||||||
() => {
|
.then(
|
||||||
if (active) setError('实例加载失败。');
|
(data) => {
|
||||||
},
|
if (active) setSnapshot(data);
|
||||||
);
|
},
|
||||||
|
(reason: unknown) => {
|
||||||
|
if (!active) return;
|
||||||
|
if (reason instanceof DOMException && reason.name === 'AbortError') return;
|
||||||
|
setError('实例加载失败。');
|
||||||
|
},
|
||||||
|
);
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [attempt, dataSource, initialData, refreshSignal]);
|
}, [attempt, dataSource, initialData, refreshSignal]);
|
||||||
|
|
||||||
@@ -209,11 +252,6 @@ export function FleetPage({
|
|||||||
};
|
};
|
||||||
}, [snapshot]);
|
}, [snapshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectAllRef.current) {
|
|
||||||
selectAllRef.current.indeterminate = model.visibleSelection.indeterminate;
|
|
||||||
}
|
|
||||||
}, [model.visibleSelection.indeterminate]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (page !== model.page) setPage(model.page);
|
if (page !== model.page) setPage(model.page);
|
||||||
}, [model.page, page]);
|
}, [model.page, page]);
|
||||||
@@ -222,13 +260,6 @@ export function FleetPage({
|
|||||||
action();
|
action();
|
||||||
setPage(1);
|
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 {
|
function toggleOne(id: string, checked: boolean): void {
|
||||||
setSelectedIds((current) => {
|
setSelectedIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
@@ -247,14 +278,6 @@ export function FleetPage({
|
|||||||
return next;
|
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<void> {
|
async function beginDelete(id: string): Promise<void> {
|
||||||
const request = ++deleteRequestRef.current;
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 = (
|
const selectFilter = (
|
||||||
label: string,
|
label: string,
|
||||||
value: string,
|
value: string,
|
||||||
@@ -362,7 +534,32 @@ export function FleetPage({
|
|||||||
) : null}
|
) : null}
|
||||||
{batchOpen ? (
|
{batchOpen ? (
|
||||||
<div className="batch-entry" role="region" aria-label="批量操作入口">
|
<div className="batch-entry" role="region" aria-label="批量操作入口">
|
||||||
请为已选择的 {model.selectedIds.length} 项选择操作。
|
<div className="batch-entry-copy">
|
||||||
|
<strong>批量操作</strong>
|
||||||
|
<p>已选择 {model.selectedIds.length} 项。重启将逐个实例安全确认后执行。</p>
|
||||||
|
</div>
|
||||||
|
<div className="batch-entry-actions" role="group" aria-label="批量重启操作">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={batchBusy || model.selectedIds.length === 0}
|
||||||
|
onClick={() => void runBatch('service-restart')}
|
||||||
|
>
|
||||||
|
批量重启服务
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger-button"
|
||||||
|
disabled={batchBusy || model.selectedIds.length === 0}
|
||||||
|
onClick={() => void runBatch('system-reboot')}
|
||||||
|
>
|
||||||
|
批量系统重启
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{batchProgress ? (
|
||||||
|
<p role="status" className="batch-progress">
|
||||||
|
{batchProgress.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="fleet-toolbar">
|
<div className="fleet-toolbar">
|
||||||
@@ -393,7 +590,7 @@ export function FleetPage({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<details className="fleet-advanced-filters">
|
<details className="fleet-advanced-filters">
|
||||||
<summary>更多筛选与列设置</summary>
|
<summary>更多筛选</summary>
|
||||||
<div className="fleet-advanced-filter-grid">
|
<div className="fleet-advanced-filter-grid">
|
||||||
<label>
|
<label>
|
||||||
<span>认证</span>
|
<span>认证</span>
|
||||||
@@ -419,30 +616,44 @@ export function FleetPage({
|
|||||||
)}
|
)}
|
||||||
{selectFilter('版本', version, setVersion, model.facets.versions, '全部版本')}
|
{selectFilter('版本', version, setVersion, model.facets.versions, '全部版本')}
|
||||||
{selectFilter('标签', tag, setTag, model.facets.tags, '全部标签')}
|
{selectFilter('标签', tag, setTag, model.facets.tags, '全部标签')}
|
||||||
<div className="column-picker">
|
<label>
|
||||||
|
<span>排序</span>
|
||||||
|
<select
|
||||||
|
aria-label="排序"
|
||||||
|
value={`${sort.column}:${sort.direction}`}
|
||||||
|
onChange={(event) => {
|
||||||
|
const [column, direction] = event.currentTarget.value.split(':') as [
|
||||||
|
FleetSortColumn,
|
||||||
|
SortDirection,
|
||||||
|
];
|
||||||
|
resetPage(() => setSort({ column, direction }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(Object.keys(SORT_COLUMN_LABELS) as FleetSortColumn[]).flatMap((column) => [
|
||||||
|
<option key={`${column}:asc`} value={`${column}:asc`}>
|
||||||
|
{SORT_COLUMN_LABELS[column]} · 升序
|
||||||
|
</option>,
|
||||||
|
<option key={`${column}:desc`} value={`${column}:desc`}>
|
||||||
|
{SORT_COLUMN_LABELS[column]} · 降序
|
||||||
|
</option>,
|
||||||
|
])}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="fleet-select-all">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-expanded={columnsOpen}
|
onClick={() => toggleVisible(true)}
|
||||||
onClick={() => setColumnsOpen((open) => !open)}
|
disabled={model.rows.length === 0}
|
||||||
>
|
>
|
||||||
选择列
|
全选本页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleVisible(false)}
|
||||||
|
disabled={model.selectedIds.length === 0}
|
||||||
|
>
|
||||||
|
清空选择
|
||||||
</button>
|
</button>
|
||||||
{columnsOpen ? (
|
|
||||||
<fieldset>
|
|
||||||
<legend>显示的列</legend>
|
|
||||||
{ALL_COLUMNS.map((column) => (
|
|
||||||
<label key={column}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
aria-label={`显示${COLUMN_LABELS[column]}列`}
|
|
||||||
checked={shownColumns.has(column)}
|
|
||||||
onChange={() => toggleColumn(column)}
|
|
||||||
/>
|
|
||||||
{COLUMN_LABELS[column]}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</fieldset>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -450,7 +661,7 @@ export function FleetPage({
|
|||||||
|
|
||||||
{!snapshot && !error ? (
|
{!snapshot && !error ? (
|
||||||
<p role="status" aria-label="实例加载状态">
|
<p role="status" aria-label="实例加载状态">
|
||||||
正在加载实例…
|
正在加载实例列表…
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -481,11 +692,19 @@ export function FleetPage({
|
|||||||
<div className="fleet-card-grid">
|
<div className="fleet-card-grid">
|
||||||
{model.rows.map((row) => (
|
{model.rows.map((row) => (
|
||||||
<article
|
<article
|
||||||
className="fleet-card"
|
className={`fleet-card${row.selected ? ' is-selected' : ''}`}
|
||||||
aria-label={`${row.displayName} 实例概览`}
|
aria-label={`${row.displayName} 实例概览`}
|
||||||
key={row.id}
|
key={row.id}
|
||||||
>
|
>
|
||||||
<header>
|
<header>
|
||||||
|
<label className="fleet-card-select touch-target">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`选择 ${row.displayName}`}
|
||||||
|
checked={row.selected}
|
||||||
|
onChange={(event) => toggleOne(row.id, event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<a
|
<a
|
||||||
className="fleet-card-entry"
|
className="fleet-card-entry"
|
||||||
href={`/instances/${encodeURIComponent(row.id)}/overview`}
|
href={`/instances/${encodeURIComponent(row.id)}/overview`}
|
||||||
@@ -499,6 +718,19 @@ export function FleetPage({
|
|||||||
{STATUS_LABELS[row.statusKind]}
|
{STATUS_LABELS[row.statusKind]}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
{(() => {
|
||||||
|
const origin = canonicalHttpOrigin(row.instance.url);
|
||||||
|
return (
|
||||||
|
<p className="fleet-card-origin" aria-label={`${row.displayName} 源地址`}>
|
||||||
|
{origin ?? '源地址无效'}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{row.anomalies.length > 0 ? (
|
||||||
|
<p className="fleet-card-anomalies" aria-label={`${row.displayName} 异常`}>
|
||||||
|
{row.anomalies.join(', ')}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<dl className="fleet-card-metrics">
|
<dl className="fleet-card-metrics">
|
||||||
<div className="fleet-card-phone">
|
<div className="fleet-card-phone">
|
||||||
<dt>手机号</dt>
|
<dt>手机号</dt>
|
||||||
@@ -582,6 +814,23 @@ export function FleetPage({
|
|||||||
role="group"
|
role="group"
|
||||||
aria-label="实例管理操作"
|
aria-label="实例管理操作"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`重启服务 ${row.displayName}`}
|
||||||
|
disabled={cardAction?.id === row.id && cardAction.busy}
|
||||||
|
onClick={() => void runCardAction(row.id, 'service-restart')}
|
||||||
|
>
|
||||||
|
重启服务
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger-button"
|
||||||
|
aria-label={`系统重启 ${row.displayName}`}
|
||||||
|
disabled={cardAction?.id === row.id && cardAction.busy}
|
||||||
|
onClick={() => void runCardAction(row.id, 'system-reboot')}
|
||||||
|
>
|
||||||
|
系统重启
|
||||||
|
</button>
|
||||||
<a
|
<a
|
||||||
href={`/settings/instances/${encodeURIComponent(row.id)}`}
|
href={`/settings/instances/${encodeURIComponent(row.id)}`}
|
||||||
aria-label={`编辑 ${row.displayName}`}
|
aria-label={`编辑 ${row.displayName}`}
|
||||||
@@ -597,6 +846,14 @@ export function FleetPage({
|
|||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{cardAction?.id === row.id ? (
|
||||||
|
<p
|
||||||
|
role={cardAction.error ? 'alert' : 'status'}
|
||||||
|
className={cardAction.error ? 'card-operation-error' : 'card-operation-success'}
|
||||||
|
>
|
||||||
|
{cardAction.error ?? cardAction.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{deleteId === row.id ? (
|
{deleteId === row.id ? (
|
||||||
<div className="card-delete-confirmation">
|
<div className="card-delete-confirmation">
|
||||||
<p>删除后无法撤销。</p>
|
<p>删除后无法撤销。</p>
|
||||||
@@ -650,123 +907,27 @@ export function FleetPage({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="fleet-details" aria-labelledby="fleet-details-title">
|
<nav className="pagination" aria-label="实例分页">
|
||||||
<h2 id="fleet-details-title">高级详细列表</h2>
|
<button
|
||||||
<div className="table-scroll" tabIndex={0}>
|
type="button"
|
||||||
<table className="dense-table">
|
aria-label="上一页"
|
||||||
<caption>实例列表</caption>
|
disabled={model.page === 1}
|
||||||
<thead>
|
onClick={() => setPage((value) => value - 1)}
|
||||||
<tr>
|
>
|
||||||
<th scope="col" className="select-column">
|
上一页
|
||||||
<label className="touch-target">
|
</button>
|
||||||
<input
|
<span>
|
||||||
ref={selectAllRef}
|
第 {model.page} 页,共 {model.pageCount} 页
|
||||||
type="checkbox"
|
</span>
|
||||||
aria-label="选择当前页全部实例"
|
<button
|
||||||
checked={model.visibleSelection.checked}
|
type="button"
|
||||||
onChange={(event) => toggleVisible(event.currentTarget.checked)}
|
aria-label="下一页"
|
||||||
/>
|
disabled={model.page === model.pageCount}
|
||||||
</label>
|
onClick={() => setPage((value) => value + 1)}
|
||||||
</th>
|
>
|
||||||
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
|
下一页
|
||||||
<th
|
</button>
|
||||||
key={column}
|
</nav>
|
||||||
scope="col"
|
|
||||||
aria-sort={
|
|
||||||
column !== 'origin' && sort.column === column
|
|
||||||
? sort.direction === 'asc'
|
|
||||||
? 'ascending'
|
|
||||||
: 'descending'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{column === 'origin' ? (
|
|
||||||
COLUMN_LABELS[column]
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => changeSort(column)}
|
|
||||||
aria-label={`按${COLUMN_LABELS[column]}排序`}
|
|
||||||
>
|
|
||||||
{COLUMN_LABELS[column]}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{model.rows.map((row) => {
|
|
||||||
const origin = canonicalHttpOrigin(row.instance.url);
|
|
||||||
const cells: Readonly<Record<string, React.ReactNode>> = {
|
|
||||||
name: (
|
|
||||||
<th scope="row">
|
|
||||||
<a href={`/instances/${encodeURIComponent(row.id)}/overview`}>
|
|
||||||
{row.displayName}
|
|
||||||
</a>
|
|
||||||
<small>{row.id}</small>
|
|
||||||
</th>
|
|
||||||
),
|
|
||||||
status: (
|
|
||||||
<td>
|
|
||||||
<span className={`status status-${row.statusKind}`}>
|
|
||||||
{STATUS_LABELS[row.statusKind]}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
),
|
|
||||||
latency: <td>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</td>,
|
|
||||||
version: <td>{row.version ?? '—'}</td>,
|
|
||||||
capabilities: (
|
|
||||||
<td>{row.capabilities.map(capabilityLabel).join(', ') || '—'}</td>
|
|
||||||
),
|
|
||||||
tags: <td>{row.tags.join(', ') || '—'}</td>,
|
|
||||||
freshness: <td>{FRESHNESS_LABELS[row.freshness] ?? row.freshness}</td>,
|
|
||||||
anomalies: <td>{row.anomalies.join(', ') || '—'}</td>,
|
|
||||||
origin: <td>{origin ? <span>{origin}</span> : <span>源地址无效</span>}</td>,
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<tr key={row.id} aria-selected={row.selected}>
|
|
||||||
<td>
|
|
||||||
<label className="touch-target">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
aria-label={`选择 ${row.displayName}`}
|
|
||||||
checked={row.selected}
|
|
||||||
onChange={(event) => toggleOne(row.id, event.currentTarget.checked)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</td>
|
|
||||||
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
|
|
||||||
<Fragment key={column}>{cells[column]}</Fragment>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<nav className="pagination" aria-label="实例分页">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="上一页"
|
|
||||||
disabled={model.page === 1}
|
|
||||||
onClick={() => setPage((value) => value - 1)}
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</button>
|
|
||||||
<span>
|
|
||||||
第 {model.page} 页,共 {model.pageCount} 页
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="下一页"
|
|
||||||
disabled={model.page === model.pageCount}
|
|
||||||
onClick={() => setPage((value) => value + 1)}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</section>
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface FleetInstance {
|
|||||||
readonly url: string;
|
readonly url: string;
|
||||||
readonly description?: string;
|
readonly description?: string;
|
||||||
readonly tags?: readonly string[];
|
readonly tags?: readonly string[];
|
||||||
|
/** Optional config revision for prepare/execute CAS when available from the control plane. */
|
||||||
|
readonly revision?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FleetStatus {
|
export interface FleetStatus {
|
||||||
|
|||||||
@@ -83,8 +83,9 @@ describe('Phase 6.1 Overview / System read slice', () => {
|
|||||||
}
|
}
|
||||||
expect(within(screen.getByRole('region', { name: '设备' })).getByText('SIMBox 8')).toBeTruthy();
|
expect(within(screen.getByRole('region', { name: '设备' })).getByText('SIMBox 8')).toBeTruthy();
|
||||||
expect(screen.getByText(/观测时间:2026-07-17T10:00:00Z/)).toBeTruthy();
|
expect(screen.getByText(/观测时间:2026-07-17T10:00:00Z/)).toBeTruthy();
|
||||||
expect(screen.queryByRole('button', { name: /restart/i })).toBeNull();
|
expect(screen.getByRole('button', { name: '重启服务' })).toBeTruthy();
|
||||||
expect(screen.getByText(/重启不可用.*R3/i)).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 () => {
|
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('46.7 °C')).toBeTruthy();
|
||||||
expect(within(operations).getByText('13800138000')).toBeTruthy();
|
expect(within(operations).getByText('13800138000')).toBeTruthy();
|
||||||
expect(screen.queryByText(/没有可用的安全概览只读数据源/i)).toBeNull();
|
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 () => {
|
it('shows authentication-required without loading or leaking prior owner data', async () => {
|
||||||
|
|||||||
@@ -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 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 OverviewFieldValue = string | number | boolean | null;
|
||||||
export type OverviewSection = Readonly<Record<string, OverviewFieldValue>>;
|
export type OverviewSection = Readonly<Record<string, OverviewFieldValue>>;
|
||||||
@@ -25,6 +30,8 @@ export interface OverviewSystemPageProps {
|
|||||||
readonly dataSource?: OverviewDataSource;
|
readonly dataSource?: OverviewDataSource;
|
||||||
/** Change this value when an external owner has requested a refresh. */
|
/** Change this value when an external owner has requested a refresh. */
|
||||||
readonly refreshSignal?: unknown;
|
readonly refreshSignal?: unknown;
|
||||||
|
readonly operationClient?: OperationClient;
|
||||||
|
readonly revision?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReadState =
|
type ReadState =
|
||||||
@@ -166,10 +173,78 @@ export function OverviewSystemPage({
|
|||||||
instance,
|
instance,
|
||||||
dataSource,
|
dataSource,
|
||||||
refreshSignal,
|
refreshSignal,
|
||||||
|
operationClient,
|
||||||
|
revision,
|
||||||
}: OverviewSystemPageProps) {
|
}: OverviewSystemPageProps) {
|
||||||
const requestOwner = useRef(0);
|
const requestOwner = useRef(0);
|
||||||
const [retry, setRetry] = useState(0);
|
const [retry, setRetry] = useState(0);
|
||||||
const [state, setState] = useState<ReadState>({ kind: 'idle' });
|
const [state, setState] = useState<ReadState>({ 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<void> {
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const request = ++requestOwner.current;
|
const request = ++requestOwner.current;
|
||||||
@@ -212,37 +287,95 @@ export function OverviewSystemPage({
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [dataSource, instance.authentication, instance.id, refreshSignal, retry]);
|
}, [dataSource, instance.authentication, instance.id, refreshSignal, retry]);
|
||||||
|
|
||||||
|
const systemOperations = (
|
||||||
|
<section className="overview-operations" aria-label="系统操作">
|
||||||
|
<h2>系统操作</h2>
|
||||||
|
<p>服务重启与系统重启均为高风险操作,执行前会再次确认。</p>
|
||||||
|
<div className="fleet-card-actions" role="group" aria-label="重启操作">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionState.busy || !effectiveRevision}
|
||||||
|
onClick={() => void runOverviewAction('service')}
|
||||||
|
>
|
||||||
|
重启服务
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger-button"
|
||||||
|
disabled={actionState.busy || !effectiveRevision}
|
||||||
|
onClick={() => void runOverviewAction('system')}
|
||||||
|
>
|
||||||
|
系统重启
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!effectiveRevision ? (
|
||||||
|
<p role="status">缺少配置版本,暂不可执行重启。请从实例列表进入并刷新后重试。</p>
|
||||||
|
) : null}
|
||||||
|
{actionState.error ? (
|
||||||
|
<p role="alert" className="card-operation-error">
|
||||||
|
{actionState.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{actionState.message ? (
|
||||||
|
<p role="status" className="card-operation-success">
|
||||||
|
{actionState.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
if (instance.authentication === 'auth-required')
|
if (instance.authentication === 'auth-required')
|
||||||
return (
|
return (
|
||||||
<div className="state-panel state-error" role="alert">
|
<div className="overview-system">
|
||||||
需要先完成认证,才能读取概览数据。
|
<div className="state-panel state-error" role="alert">
|
||||||
|
需要先完成认证,才能读取概览数据。
|
||||||
|
</div>
|
||||||
|
{systemOperations}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!dataSource) return <ResourceSummary instance={instance} />;
|
if (!dataSource)
|
||||||
|
return (
|
||||||
|
<div className="overview-system">
|
||||||
|
<ResourceSummary instance={instance} />
|
||||||
|
<p role="status" aria-label="概览加载状态">
|
||||||
|
未提供安全概览只读数据源,但系统操作仍可使用。
|
||||||
|
</p>
|
||||||
|
{systemOperations}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const retainedSnapshot =
|
const retainedSnapshot =
|
||||||
state.kind === 'loading' || state.kind === 'error' ? state.snapshot : undefined;
|
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)
|
if ((state.kind === 'loading' || state.kind === 'idle') && !retainedSnapshot)
|
||||||
return (
|
return (
|
||||||
<p role="status" aria-label="概览加载状态">
|
<div className="overview-system">
|
||||||
正在加载概览…
|
<ResourceSummary instance={instance} />
|
||||||
</p>
|
<p role="status" aria-label="概览加载状态">
|
||||||
|
正在加载概览…
|
||||||
|
</p>
|
||||||
|
{systemOperations}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (state.kind === 'error' && !state.snapshot)
|
if (state.kind === 'error' && !state.snapshot)
|
||||||
return (
|
return (
|
||||||
<div className="state-panel state-error" role="alert">
|
<div className="overview-system">
|
||||||
<p>无法加载概览: {state.message}</p>
|
<ResourceSummary instance={instance} />
|
||||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
<div className="state-panel state-error" role="alert">
|
||||||
重试加载概览
|
<p>无法加载概览: {state.message}</p>
|
||||||
</button>
|
<button type="button" onClick={() => setRetry((value) => value + 1)}>
|
||||||
|
重试加载概览
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{systemOperations}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const snapshot = state.kind === 'ready' ? state.snapshot : retainedSnapshot;
|
if (!snapshot) return <div className="overview-system">{systemOperations}</div>;
|
||||||
if (!snapshot) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overview-system">
|
<div className="overview-system">
|
||||||
@@ -267,10 +400,7 @@ export function OverviewSystemPage({
|
|||||||
<StructuredSection key={key} label={label} values={snapshot[key]} />
|
<StructuredSection key={key} label={label} values={snapshot[key]} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<details className="system-operation-note">
|
{systemOperations}
|
||||||
<summary>系统操作说明</summary>
|
|
||||||
<p>重启不可用,因为后端尚未实现 R3 重启操作,因此不提供任何操作。</p>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1222,6 +1222,94 @@ main ul[aria-label='已配置实例'] {
|
|||||||
flex: 0 0 auto;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
@@ -1232,3 +1320,28 @@ main ul[aria-label='已配置实例'] {
|
|||||||
animation-iteration-count: 1 !important;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user