feat(fleet): show instance resource metrics
This commit is contained in:
@@ -3,25 +3,34 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { createFleetApiDataSource } from './fleet-api-data-source.js';
|
||||
|
||||
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(
|
||||
async () =>
|
||||
async (input: RequestInfo | URL) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
id: 'alpha',
|
||||
name: 'Alpha',
|
||||
origin: 'https://alpha.example/admin',
|
||||
tags: ['lab'],
|
||||
revision: 1,
|
||||
capabilityStatus: 'unknown',
|
||||
freshness: 'unknown',
|
||||
credentialConfigured: false,
|
||||
},
|
||||
],
|
||||
page: { page: 1, pageSize: 20, total: 1 },
|
||||
}),
|
||||
JSON.stringify(
|
||||
String(input).endsWith('/resources')
|
||||
? {
|
||||
cpuPercent: 23.4,
|
||||
memoryPercent: 67.8,
|
||||
maxTemperatureCelsius: 52.6,
|
||||
phoneNumbers: ['13800000000'],
|
||||
}
|
||||
: {
|
||||
items: [
|
||||
{
|
||||
id: 'alpha',
|
||||
name: 'Alpha',
|
||||
origin: 'https://alpha.example/admin',
|
||||
tags: ['lab'],
|
||||
revision: 1,
|
||||
capabilityStatus: 'unknown',
|
||||
freshness: 'unknown',
|
||||
credentialConfigured: false,
|
||||
},
|
||||
],
|
||||
page: { page: 1, pageSize: 20, total: 1 },
|
||||
},
|
||||
),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
@@ -29,10 +38,20 @@ describe('Fleet API data source', () => {
|
||||
expect(snapshot.instances).toEqual([
|
||||
{ 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(
|
||||
'/api/v1/instances',
|
||||
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 () => {
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
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 {
|
||||
readonly items?: readonly unknown[];
|
||||
readonly page?: {
|
||||
readonly page?: unknown;
|
||||
readonly pageSize?: unknown;
|
||||
readonly total?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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 {
|
||||
return {
|
||||
async load(): Promise<FleetSnapshot> {
|
||||
async load(signal): Promise<FleetSnapshot> {
|
||||
const response = await fetcher('/api/v1/instances', {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { accept: 'application/json' },
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Fleet request failed (${response.status}).`);
|
||||
const body = (await response.json()) as InstancePage;
|
||||
if (!Array.isArray(body.items)) throw new Error('Fleet response is invalid.');
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
@@ -421,6 +426,24 @@ export function FleetPage({
|
||||
</span>
|
||||
</header>
|
||||
<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>
|
||||
<dt>延迟</dt>
|
||||
<dd>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</dd>
|
||||
|
||||
@@ -25,7 +25,16 @@ export interface FleetStatus {
|
||||
readonly reachable: boolean;
|
||||
readonly authenticated?: boolean;
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user