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:
@@ -1,88 +1,142 @@
|
||||
import type { FleetDataSource, FleetSnapshot } from './fleet-page.js';
|
||||
import type { FleetInstance, FleetStatus } from './fleet-table-view-model.js';
|
||||
|
||||
interface InstancePage {
|
||||
readonly items?: readonly unknown[];
|
||||
}
|
||||
const integer = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value);
|
||||
const finite = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value);
|
||||
const string = (value: unknown): value is string => typeof value === 'string' && value.length > 0;
|
||||
const record = (value: unknown): Record<string, unknown> | undefined =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
function parseInstance(value: unknown): FleetInstance {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new Error('Fleet response is invalid.');
|
||||
const item = value as Record<string, unknown>;
|
||||
function parseInstance(value: unknown): FleetInstance | undefined {
|
||||
const item = record(value);
|
||||
if (
|
||||
typeof item.id !== 'string' ||
|
||||
typeof item.name !== 'string' ||
|
||||
typeof item.origin !== 'string' ||
|
||||
!item ||
|
||||
!string(item.id) ||
|
||||
!string(item.name) ||
|
||||
!string(item.origin) ||
|
||||
!Array.isArray(item.tags) ||
|
||||
!item.tags.every((tag) => typeof tag === 'string')
|
||||
)
|
||||
throw new Error('Fleet response is invalid.');
|
||||
return { id: item.id, name: item.name, url: item.origin, tags: item.tags };
|
||||
return undefined;
|
||||
const revision =
|
||||
integer(item.revision) && item.revision > 0 ? (item.revision as number) : undefined;
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
url: item.origin,
|
||||
tags: item.tags as string[],
|
||||
...(revision === undefined ? {} : { revision }),
|
||||
};
|
||||
}
|
||||
|
||||
type Resources = NonNullable<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;
|
||||
function parseResources(value: unknown): NonNullable<FleetStatus['summary']> {
|
||||
const body = record(value) ?? {};
|
||||
const resources: {
|
||||
cpuPercent?: number;
|
||||
memoryPercent?: number;
|
||||
maxTemperatureCelsius?: number;
|
||||
phoneNumbers?: string[];
|
||||
} = {};
|
||||
if (finite(body.cpuPercent)) resources.cpuPercent = body.cpuPercent;
|
||||
if (finite(body.memoryPercent)) resources.memoryPercent = body.memoryPercent;
|
||||
if (finite(body.maxTemperatureCelsius))
|
||||
resources.maxTemperatureCelsius = body.maxTemperatureCelsius;
|
||||
if (
|
||||
Array.isArray(body.phoneNumbers) &&
|
||||
body.phoneNumbers.every((item) => typeof item === 'string' && item.length > 0)
|
||||
)
|
||||
resources.phoneNumbers = body.phoneNumbers as string[];
|
||||
return {
|
||||
...(cpuPercent === undefined ? {} : { cpuPercent }),
|
||||
...(memoryPercent === undefined ? {} : { memoryPercent }),
|
||||
...(maxTemperatureCelsius === undefined ? {} : { maxTemperatureCelsius }),
|
||||
...(phoneNumbers?.length ? { phoneNumbers } : {}),
|
||||
freshness: 'fresh',
|
||||
...(Object.keys(resources).length > 0 ? { resources } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function readJson(response: Response): Promise<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 {
|
||||
return {
|
||||
async load(signal): Promise<FleetSnapshot> {
|
||||
const response = await fetcher('/api/v1/instances', {
|
||||
async load(signal, onPartial) {
|
||||
const listResponse = await fetcher('/api/v1/instances', {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { accept: 'application/json' },
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Fleet request failed (${response.status}).`);
|
||||
const body = (await response.json()) as InstancePage;
|
||||
if (!Array.isArray(body.items)) throw new Error('Fleet response is invalid.');
|
||||
const instances = body.items.map(parseInstance);
|
||||
const entries = await Promise.all(
|
||||
instances.map(async (instance): Promise<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,
|
||||
{
|
||||
const listBody = record(await readJson(listResponse));
|
||||
if (
|
||||
!listResponse.ok ||
|
||||
!listBody ||
|
||||
!Array.isArray(listBody.items) ||
|
||||
!record(listBody.page)
|
||||
)
|
||||
throw new Error('Fleet response is invalid.');
|
||||
const instances = listBody.items.map(parseInstance);
|
||||
if (instances.some((item) => !item)) throw new Error('Fleet response is invalid.');
|
||||
const readyInstances = instances as FleetInstance[];
|
||||
const partial: FleetSnapshot = {
|
||||
instances: readyInstances,
|
||||
statuses: skeletonStatuses(readyInstances),
|
||||
};
|
||||
onPartial?.(partial);
|
||||
|
||||
const statuses = new Map(
|
||||
await Promise.all(
|
||||
readyInstances.map(async (instance) => {
|
||||
try {
|
||||
const response = await fetcher(
|
||||
`/api/v1/instances/${encodeURIComponent(instance.id)}/resources`,
|
||||
{
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { accept: 'application/json' },
|
||||
...(signal ? { signal } : {}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error('resource unavailable');
|
||||
const status: FleetStatus = {
|
||||
reachable: true,
|
||||
authenticated: true,
|
||||
summary: { resources: parseResources(await resourceResponse.json()) },
|
||||
},
|
||||
];
|
||||
} catch {
|
||||
return [instance.id, { reachable: false, summary: { resources: {} } }];
|
||||
}
|
||||
}),
|
||||
summary: parseResources(await readJson(response)),
|
||||
};
|
||||
return [instance.id, status] as const;
|
||||
} catch {
|
||||
const status: FleetStatus = {
|
||||
reachable: true,
|
||||
authenticated: true,
|
||||
summary: { freshness: 'unknown' },
|
||||
};
|
||||
return [instance.id, status] as const;
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
return { instances, statuses: new Map(entries) };
|
||||
const complete: FleetSnapshot = { instances: readyInstances, statuses };
|
||||
onPartial?.(complete);
|
||||
return complete;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user