feat(fleet): show instance resource metrics
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
export interface UpstreamRequest {
|
export interface UpstreamRequest {
|
||||||
readonly url: string;
|
readonly url: string;
|
||||||
readonly method: 'POST';
|
readonly method: 'GET' | 'POST';
|
||||||
readonly headers: Readonly<Record<string, string>>;
|
readonly headers: Readonly<Record<string, string>>;
|
||||||
/** One-shot secret. Request implementations must not log or persist this field. */
|
/** One-shot secret. Request implementations must not log or persist this field. */
|
||||||
readonly secret?: string;
|
readonly secret?: string;
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { parseSim, parseStats } from './instance-resource-service.js';
|
||||||
|
|
||||||
|
const response = (body: unknown) => ({
|
||||||
|
status: 200,
|
||||||
|
headers: {},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('instance resource allowlist parsing', () => {
|
||||||
|
it('extracts CPU, memory and only the highest valid temperature', () => {
|
||||||
|
expect(
|
||||||
|
parseStats(
|
||||||
|
response({
|
||||||
|
data: {
|
||||||
|
cpu_load: { load_percent: 23.4, secret: 'drop' },
|
||||||
|
memory: { used_percent: 67.8, total_bytes: 123 },
|
||||||
|
temperature: [
|
||||||
|
{ label: 'a', temperature: 41.2 },
|
||||||
|
{ label: 'b', temperature: 52.6 },
|
||||||
|
{ label: 'bad', temperature: 900 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual({ cpuPercent: 23.4, memoryPercent: 67.8, maxTemperatureCelsius: 52.6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts, validates, deduplicates and bounds phone numbers only', () => {
|
||||||
|
expect(
|
||||||
|
parseSim(
|
||||||
|
response({
|
||||||
|
data: {
|
||||||
|
phone_numbers: ['+86 138-0000-0000', '+86 138-0000-0000', 'bad<script>'],
|
||||||
|
imsi: 'drop',
|
||||||
|
iccid: 'drop',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual({ phoneNumbers: ['+86 138-0000-0000'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed for malformed and oversized payloads', () => {
|
||||||
|
expect(parseStats({ status: 200, headers: {}, body: '{' })).toEqual({});
|
||||||
|
expect(parseSim({ status: 200, headers: {}, body: 'x'.repeat(40_000) })).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { InstanceService } from '../instances/instance-service.js';
|
||||||
|
import type {
|
||||||
|
InstanceSessionStore,
|
||||||
|
UpstreamResponse,
|
||||||
|
UpstreamSessionClientOptions,
|
||||||
|
} from '../connections/upstream-session-client.js';
|
||||||
|
|
||||||
|
export interface InstanceResources {
|
||||||
|
readonly cpuPercent?: number;
|
||||||
|
readonly memoryPercent?: number;
|
||||||
|
readonly maxTemperatureCelsius?: number;
|
||||||
|
readonly phoneNumbers?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 32_768;
|
||||||
|
const record = (value: unknown): Record<string, unknown> | undefined =>
|
||||||
|
value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
const ranged = (value: unknown, minimum: number, maximum: number): number | undefined =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value) && value >= minimum && value <= maximum
|
||||||
|
? value
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
function data(response: UpstreamResponse): Record<string, unknown> | undefined {
|
||||||
|
if (response.status < 200 || response.status >= 300) return undefined;
|
||||||
|
if (Buffer.byteLength(response.body, 'utf8') > MAX_BODY_BYTES) return undefined;
|
||||||
|
try {
|
||||||
|
return record(record(JSON.parse(response.body))?.data);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStats(response: UpstreamResponse): InstanceResources {
|
||||||
|
const value = data(response);
|
||||||
|
if (!value) return {};
|
||||||
|
const cpuPercent = ranged(record(value.cpu_load)?.load_percent, 0, 100);
|
||||||
|
const memoryPercent = ranged(record(value.memory)?.used_percent, 0, 100);
|
||||||
|
const temperatures = Array.isArray(value.temperature)
|
||||||
|
? value.temperature
|
||||||
|
.map((item) => ranged(record(item)?.temperature, -100, 300))
|
||||||
|
.filter((item): item is number => item !== undefined)
|
||||||
|
: [];
|
||||||
|
const maxTemperatureCelsius = temperatures.length > 0 ? Math.max(...temperatures) : undefined;
|
||||||
|
return {
|
||||||
|
...(cpuPercent === undefined ? {} : { cpuPercent }),
|
||||||
|
...(memoryPercent === undefined ? {} : { memoryPercent }),
|
||||||
|
...(maxTemperatureCelsius === undefined ? {} : { maxTemperatureCelsius }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSim(response: UpstreamResponse): InstanceResources {
|
||||||
|
const value = data(response);
|
||||||
|
if (!value || !Array.isArray(value.phone_numbers)) return {};
|
||||||
|
const phoneNumbers = value.phone_numbers.filter(
|
||||||
|
(item): item is string =>
|
||||||
|
typeof item === 'string' &&
|
||||||
|
item.length > 0 &&
|
||||||
|
item.length <= 32 &&
|
||||||
|
/^[+0-9 ()-]+$/.test(item),
|
||||||
|
);
|
||||||
|
return phoneNumbers.length > 0 ? { phoneNumbers: [...new Set(phoneNumbers)].slice(0, 16) } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InstanceResourceService {
|
||||||
|
constructor(
|
||||||
|
private readonly options: {
|
||||||
|
readonly instances: InstanceService;
|
||||||
|
readonly sessions: InstanceSessionStore;
|
||||||
|
readonly request: UpstreamSessionClientOptions['request'];
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async get(instanceId: string): Promise<InstanceResources> {
|
||||||
|
const [instance, session] = await Promise.all([
|
||||||
|
this.options.instances.get(instanceId),
|
||||||
|
Promise.resolve(this.options.sessions.sessionFor(instanceId)),
|
||||||
|
]);
|
||||||
|
if (!instance || !session || session.origin !== instance.origin) return {};
|
||||||
|
const get = (path: '/api/stats' | '/api/sim') =>
|
||||||
|
this.options.request({
|
||||||
|
url: `${instance.origin}${path}`,
|
||||||
|
method: 'GET',
|
||||||
|
headers: { cookie: session.cookie, accept: 'application/json' },
|
||||||
|
});
|
||||||
|
const [stats, sim] = await Promise.allSettled([get('/api/stats'), get('/api/sim')]);
|
||||||
|
return {
|
||||||
|
...(stats.status === 'fulfilled' ? parseStats(stats.value) : {}),
|
||||||
|
...(sim.status === 'fulfilled' ? parseSim(sim.value) : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import { JobQueryService } from './application/jobs/job-query-service.js';
|
|||||||
import { registerJobRoutes } from './interface/http/job-routes.js';
|
import { registerJobRoutes } from './interface/http/job-routes.js';
|
||||||
import { AuditQueryService } from './application/audit/audit-query-service.js';
|
import { AuditQueryService } from './application/audit/audit-query-service.js';
|
||||||
import { registerAuditRoutes } from './interface/http/audit-routes.js';
|
import { registerAuditRoutes } from './interface/http/audit-routes.js';
|
||||||
|
import { InstanceResourceService } from './application/resources/instance-resource-service.js';
|
||||||
|
|
||||||
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
||||||
request: UpstreamSessionClientOptions['request'];
|
request: UpstreamSessionClientOptions['request'];
|
||||||
@@ -61,6 +62,11 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
|||||||
: new ConnectionProbe({ db: options.db, instances, transport: options.upstream });
|
: new ConnectionProbe({ db: options.db, instances, transport: options.upstream });
|
||||||
const sessions = new InstanceSessionStore();
|
const sessions = new InstanceSessionStore();
|
||||||
const client = new UpstreamSessionClient({ sessions, request: options.upstream.request });
|
const client = new UpstreamSessionClient({ sessions, request: options.upstream.request });
|
||||||
|
const resources = new InstanceResourceService({
|
||||||
|
instances,
|
||||||
|
sessions,
|
||||||
|
request: options.upstream.request,
|
||||||
|
});
|
||||||
const resolver = new InstanceCredentialResolver({ db: options.db, store: options.store });
|
const resolver = new InstanceCredentialResolver({ db: options.db, store: options.store });
|
||||||
const login = options.now
|
const login = options.now
|
||||||
? new InstanceLoginService({ db: options.db, client, resolver, now: options.now })
|
? new InstanceLoginService({ db: options.db, client, resolver, now: options.now })
|
||||||
@@ -89,6 +95,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
|||||||
connections,
|
connections,
|
||||||
login,
|
login,
|
||||||
deletion,
|
deletion,
|
||||||
|
resources,
|
||||||
registerDeletionPreparationRoute: false,
|
registerDeletionPreparationRoute: false,
|
||||||
});
|
});
|
||||||
registerOperationRoutes(app, operationCatalogRegistry, secureExecution, deletion);
|
registerOperationRoutes(app, operationCatalogRegistry, secureExecution, deletion);
|
||||||
|
|||||||
@@ -68,8 +68,11 @@ const origin = (raw: string): URL => {
|
|||||||
};
|
};
|
||||||
export class SafeInstanceTransport {
|
export class SafeInstanceTransport {
|
||||||
constructor(private readonly options: SafeTransportOptions) {}
|
constructor(private readonly options: SafeTransportOptions) {}
|
||||||
async get(raw: string): Promise<TransportResponse> {
|
async get(
|
||||||
return this.send({ url: raw, method: 'GET', headers: {} });
|
raw: string,
|
||||||
|
headers: Readonly<Record<string, string>> = {},
|
||||||
|
): Promise<TransportResponse> {
|
||||||
|
return this.send({ url: raw, method: 'GET', headers });
|
||||||
}
|
}
|
||||||
async post(
|
async post(
|
||||||
raw: string,
|
raw: string,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { UpstreamError } from './upstream-error.js';
|
|||||||
import { OperationNotDispatchedError } from '../../application/operations/secure-operation-execution.js';
|
import { OperationNotDispatchedError } from '../../application/operations/secure-operation-execution.js';
|
||||||
|
|
||||||
export interface SafeUpstreamTransport {
|
export interface SafeUpstreamTransport {
|
||||||
get(url: string): Promise<TransportResponse>;
|
get(url: string, headers?: Readonly<Record<string, string>>): Promise<TransportResponse>;
|
||||||
post(
|
post(
|
||||||
url: string,
|
url: string,
|
||||||
headers: Readonly<Record<string, string>>,
|
headers: Readonly<Record<string, string>>,
|
||||||
@@ -46,6 +46,21 @@ export class SafeUpstreamGateway {
|
|||||||
}
|
}
|
||||||
async request(request: UpstreamRequest): Promise<UpstreamResponse> {
|
async request(request: UpstreamRequest): Promise<UpstreamResponse> {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
if (request.method === 'GET') {
|
||||||
|
if (
|
||||||
|
request.secret !== undefined ||
|
||||||
|
request.body !== undefined ||
|
||||||
|
(url.pathname !== '/api/stats' && url.pathname !== '/api/sim') ||
|
||||||
|
url.search ||
|
||||||
|
url.hash ||
|
||||||
|
url.username ||
|
||||||
|
url.password ||
|
||||||
|
typeof request.headers.cookie !== 'string' ||
|
||||||
|
!/^simadmin_session=[^;\s,]+$/.test(request.headers.cookie)
|
||||||
|
)
|
||||||
|
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
|
||||||
|
return this.options.transport.get(request.url, request.headers);
|
||||||
|
}
|
||||||
if (url.protocol !== 'https:') throw new UpstreamError('UPSTREAM_INSECURE_AUTH');
|
if (url.protocol !== 'https:') throw new UpstreamError('UPSTREAM_INSECURE_AUTH');
|
||||||
if (request.method !== 'POST') throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
|
if (request.method !== 'POST') throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
|
||||||
if (request.url.endsWith('/api/auth/login')) {
|
if (request.url.endsWith('/api/auth/login')) {
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ import {
|
|||||||
DeleteInstanceOperation,
|
DeleteInstanceOperation,
|
||||||
DeleteInstanceOperationError,
|
DeleteInstanceOperationError,
|
||||||
} from '../../application/operations/delete-instance-operation.js';
|
} from '../../application/operations/delete-instance-operation.js';
|
||||||
|
import type { InstanceResourceService } from '../../application/resources/instance-resource-service.js';
|
||||||
|
|
||||||
export interface InstanceRoutesOptions {
|
export interface InstanceRoutesOptions {
|
||||||
readonly instances: InstanceService;
|
readonly instances: InstanceService;
|
||||||
readonly connections?: ConnectionProbe;
|
readonly connections?: ConnectionProbe;
|
||||||
readonly login?: InstanceLoginService;
|
readonly login?: InstanceLoginService;
|
||||||
readonly deletion?: DeleteInstanceOperation;
|
readonly deletion?: DeleteInstanceOperation;
|
||||||
|
readonly resources?: InstanceResourceService;
|
||||||
readonly registerDeletionPreparationRoute?: boolean;
|
readonly registerDeletionPreparationRoute?: boolean;
|
||||||
}
|
}
|
||||||
const problem = (
|
const problem = (
|
||||||
@@ -336,6 +338,13 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
|
|||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
if (options.resources)
|
||||||
|
app.get(
|
||||||
|
'/api/v1/instances/:instanceId/resources',
|
||||||
|
wrap(async (request) =>
|
||||||
|
options.resources!.get((request.params as { instanceId: string }).instanceId),
|
||||||
|
),
|
||||||
|
);
|
||||||
app.patch(
|
app.patch(
|
||||||
'/api/v1/instances/:instanceId',
|
'/api/v1/instances/:instanceId',
|
||||||
{ schema: { body: instancePatchSchema } },
|
{ schema: { body: instancePatchSchema } },
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ const snapshot: FleetSnapshot = {
|
|||||||
capabilities: ['sms'],
|
capabilities: ['sms'],
|
||||||
freshness: 'stale',
|
freshness: 'stale',
|
||||||
anomalies: ['clock drift'],
|
anomalies: ['clock drift'],
|
||||||
|
resources: {
|
||||||
|
cpuPercent: 18.4,
|
||||||
|
memoryPercent: 63.2,
|
||||||
|
maxTemperatureCelsius: 46.7,
|
||||||
|
phoneNumbers: ['13800138000'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -125,6 +131,10 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
const card = screen.getByRole('article', { name: 'Bravo 实例概览' });
|
const card = screen.getByRole('article', { name: 'Bravo 实例概览' });
|
||||||
expect(within(card).getByText('40 ms')).toBeTruthy();
|
expect(within(card).getByText('40 ms')).toBeTruthy();
|
||||||
expect(within(card).getByText('2.0')).toBeTruthy();
|
expect(within(card).getByText('2.0')).toBeTruthy();
|
||||||
|
expect(within(card).getByText('18.4%')).toBeTruthy();
|
||||||
|
expect(within(card).getByText('63.2%')).toBeTruthy();
|
||||||
|
expect(within(card).getByText('46.7 °C')).toBeTruthy();
|
||||||
|
expect(within(card).getByText('13800138000')).toBeTruthy();
|
||||||
expect(within(card).getByRole('link', { name: '管理 Bravo' }).getAttribute('href')).toBe(
|
expect(within(card).getByRole('link', { name: '管理 Bravo' }).getAttribute('href')).toBe(
|
||||||
'/instances/bravo/overview',
|
'/instances/bravo/overview',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,11 +3,19 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import { createFleetApiDataSource } from './fleet-api-data-source.js';
|
import { createFleetApiDataSource } from './fleet-api-data-source.js';
|
||||||
|
|
||||||
describe('Fleet API data source', () => {
|
describe('Fleet API data source', () => {
|
||||||
it('parses the frozen InstancePage envelope and maps origin to the Fleet URL', async () => {
|
it('loads instances and their allowlisted resource summaries', async () => {
|
||||||
const fetcher = vi.fn(
|
const fetcher = vi.fn(
|
||||||
async () =>
|
async (input: RequestInfo | URL) =>
|
||||||
new Response(
|
new Response(
|
||||||
JSON.stringify({
|
JSON.stringify(
|
||||||
|
String(input).endsWith('/resources')
|
||||||
|
? {
|
||||||
|
cpuPercent: 23.4,
|
||||||
|
memoryPercent: 67.8,
|
||||||
|
maxTemperatureCelsius: 52.6,
|
||||||
|
phoneNumbers: ['13800000000'],
|
||||||
|
}
|
||||||
|
: {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: 'alpha',
|
id: 'alpha',
|
||||||
@@ -21,7 +29,8 @@ describe('Fleet API data source', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
page: { page: 1, pageSize: 20, total: 1 },
|
page: { page: 1, pageSize: 20, total: 1 },
|
||||||
}),
|
},
|
||||||
|
),
|
||||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -29,10 +38,20 @@ describe('Fleet API data source', () => {
|
|||||||
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'] },
|
||||||
]);
|
]);
|
||||||
|
expect(snapshot.statuses.get('alpha')?.summary?.resources).toEqual({
|
||||||
|
cpuPercent: 23.4,
|
||||||
|
memoryPercent: 67.8,
|
||||||
|
maxTemperatureCelsius: 52.6,
|
||||||
|
phoneNumbers: ['13800000000'],
|
||||||
|
});
|
||||||
expect(fetcher).toHaveBeenCalledWith(
|
expect(fetcher).toHaveBeenCalledWith(
|
||||||
'/api/v1/instances',
|
'/api/v1/instances',
|
||||||
expect.objectContaining({ credentials: 'same-origin' }),
|
expect.objectContaining({ credentials: 'same-origin' }),
|
||||||
);
|
);
|
||||||
|
expect(fetcher).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/instances/alpha/resources',
|
||||||
|
expect.objectContaining({ credentials: 'same-origin' }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import type { FleetDataSource, FleetSnapshot } from './fleet-page.js';
|
import type { FleetDataSource, FleetSnapshot } from './fleet-page.js';
|
||||||
import type { FleetInstance } from './fleet-table-view-model.js';
|
import type { FleetInstance, FleetStatus } from './fleet-table-view-model.js';
|
||||||
|
|
||||||
interface InstancePage {
|
interface InstancePage {
|
||||||
readonly items?: readonly unknown[];
|
readonly items?: readonly unknown[];
|
||||||
readonly page?: {
|
|
||||||
readonly page?: unknown;
|
|
||||||
readonly pageSize?: unknown;
|
|
||||||
readonly total?: unknown;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseInstance(value: unknown): FleetInstance {
|
function parseInstance(value: unknown): FleetInstance {
|
||||||
@@ -25,18 +20,69 @@ function parseInstance(value: unknown): FleetInstance {
|
|||||||
return { id: item.id, name: item.name, url: item.origin, tags: item.tags };
|
return { id: item.id, name: item.name, url: item.origin, tags: item.tags };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Resources = NonNullable<NonNullable<FleetStatus['summary']>['resources']>;
|
||||||
|
function parseResources(value: unknown): Resources {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||||
|
const item = value as Record<string, unknown>;
|
||||||
|
const number = (field: string): number | undefined => {
|
||||||
|
const fieldValue = item[field];
|
||||||
|
return typeof fieldValue === 'number' && Number.isFinite(fieldValue) ? fieldValue : undefined;
|
||||||
|
};
|
||||||
|
const cpuPercent = number('cpuPercent');
|
||||||
|
const memoryPercent = number('memoryPercent');
|
||||||
|
const maxTemperatureCelsius = number('maxTemperatureCelsius');
|
||||||
|
const phoneNumbers = Array.isArray(item.phoneNumbers)
|
||||||
|
? item.phoneNumbers.filter((phone): phone is string => typeof phone === 'string')
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
...(cpuPercent === undefined ? {} : { cpuPercent }),
|
||||||
|
...(memoryPercent === undefined ? {} : { memoryPercent }),
|
||||||
|
...(maxTemperatureCelsius === undefined ? {} : { maxTemperatureCelsius }),
|
||||||
|
...(phoneNumbers?.length ? { phoneNumbers } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDataSource {
|
export function createFleetApiDataSource(fetcher: typeof fetch = fetch): FleetDataSource {
|
||||||
return {
|
return {
|
||||||
async load(): Promise<FleetSnapshot> {
|
async load(signal): Promise<FleetSnapshot> {
|
||||||
const response = await fetcher('/api/v1/instances', {
|
const response = 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 } : {}),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`Fleet request failed (${response.status}).`);
|
if (!response.ok) throw new Error(`Fleet request failed (${response.status}).`);
|
||||||
const body = (await response.json()) as InstancePage;
|
const body = (await response.json()) as InstancePage;
|
||||||
if (!Array.isArray(body.items)) throw new Error('Fleet response is invalid.');
|
if (!Array.isArray(body.items)) throw new Error('Fleet response is invalid.');
|
||||||
return { instances: body.items.map(parseInstance), statuses: new Map() };
|
const instances = body.items.map(parseInstance);
|
||||||
|
const entries = await Promise.all(
|
||||||
|
instances.map(async (instance): Promise<readonly [string, FleetStatus]> => {
|
||||||
|
try {
|
||||||
|
const resourceResponse = await fetcher(
|
||||||
|
`/api/v1/instances/${encodeURIComponent(instance.id)}/resources`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
...(signal ? { signal } : {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!resourceResponse.ok)
|
||||||
|
return [instance.id, { reachable: false, summary: { resources: {} } }];
|
||||||
|
return [
|
||||||
|
instance.id,
|
||||||
|
{
|
||||||
|
reachable: true,
|
||||||
|
authenticated: true,
|
||||||
|
summary: { resources: parseResources(await resourceResponse.json()) },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} catch {
|
||||||
|
return [instance.id, { reachable: false, summary: { resources: {} } }];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { instances, statuses: new Map(entries) };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ function capabilityLabel(value: string): string {
|
|||||||
return CAPABILITY_LABELS[value] ?? value;
|
return CAPABILITY_LABELS[value] ?? value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const percent = (value: number | undefined): string =>
|
||||||
|
value === undefined ? '暂未获取' : `${value.toFixed(1)}%`;
|
||||||
|
const temperature = (value: number | undefined): string =>
|
||||||
|
value === undefined ? '暂未获取' : `${value.toFixed(1)} °C`;
|
||||||
|
|
||||||
export function canonicalHttpOrigin(value: string): string | null {
|
export function canonicalHttpOrigin(value: string): string | null {
|
||||||
try {
|
try {
|
||||||
const url = new URL(value);
|
const url = new URL(value);
|
||||||
@@ -421,6 +426,24 @@ export function FleetPage({
|
|||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<dl className="fleet-card-metrics">
|
<dl className="fleet-card-metrics">
|
||||||
|
<div className="fleet-card-phone">
|
||||||
|
<dt>手机号</dt>
|
||||||
|
<dd>
|
||||||
|
{row.status?.summary?.resources?.phoneNumbers?.join('、') || '暂未获取'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>CPU</dt>
|
||||||
|
<dd>{percent(row.status?.summary?.resources?.cpuPercent)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>内存</dt>
|
||||||
|
<dd>{percent(row.status?.summary?.resources?.memoryPercent)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>最高温度</dt>
|
||||||
|
<dd>{temperature(row.status?.summary?.resources?.maxTemperatureCelsius)}</dd>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>延迟</dt>
|
<dt>延迟</dt>
|
||||||
<dd>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</dd>
|
<dd>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</dd>
|
||||||
|
|||||||
@@ -25,7 +25,16 @@ export interface FleetStatus {
|
|||||||
readonly reachable: boolean;
|
readonly reachable: boolean;
|
||||||
readonly authenticated?: boolean;
|
readonly authenticated?: boolean;
|
||||||
readonly latencyMs?: number;
|
readonly latencyMs?: number;
|
||||||
readonly summary?: Readonly<Record<string, unknown>>;
|
readonly summary?: Readonly<
|
||||||
|
Record<string, unknown> & {
|
||||||
|
resources?: Readonly<{
|
||||||
|
cpuPercent?: number;
|
||||||
|
memoryPercent?: number;
|
||||||
|
maxTemperatureCelsius?: number;
|
||||||
|
phoneNumbers?: readonly string[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FleetTableOptions {
|
export interface FleetTableOptions {
|
||||||
|
|||||||
Reference in New Issue
Block a user