feat(web): rebuild fleet page with Hub-style identity guard and context actions

- Surface device identity state, carrier details and upstream link health.

- Add fleet-level identity monitoring and restart/baseband context actions.

- Apply consistent notification scoping across fleet views.
This commit is contained in:
chick
2026-09-07 00:45:28 +08:00
parent 635527dd26
commit 864e1bd90c
16 changed files with 1596 additions and 90 deletions
@@ -0,0 +1,186 @@
export type IdentityStatus = 'confirmed' | 'pending';
export type IdentityReason = 'hardware_swapped' | 'imei_claimed';
export type IdentityField = 'imei' | 'manufacturer' | 'model' | 'revision';
export interface IdentityConflict {
readonly reason: IdentityReason;
readonly instanceIds: readonly string[];
readonly instanceNames: readonly string[];
}
export interface IdentityChange {
readonly field: IdentityField;
readonly from: string;
readonly to: string;
}
export interface DeviceIdentity {
readonly instanceId: string;
readonly instanceName: string;
readonly status: IdentityStatus;
readonly reasons: readonly IdentityReason[];
readonly conflicts: readonly IdentityConflict[];
readonly imei: string;
readonly manufacturer: string;
readonly model: string;
readonly revision: string;
readonly agent: string;
readonly origin: string;
readonly fingerprint: string;
readonly confirmedFingerprint: string;
readonly changes: readonly IdentityChange[];
readonly observedAt: string;
readonly confirmedAt: string;
}
export interface IdentityOverview {
readonly items: readonly DeviceIdentity[];
readonly tracked: number;
readonly pending: number;
}
export interface IdentityDataSource {
list(signal?: AbortSignal): Promise<IdentityOverview>;
confirm(instanceId: string): Promise<DeviceIdentity>;
/** One device read; `observed` is false when the node answered without any identity field. */
refresh(instanceId: string): Promise<{ observed: boolean; identity?: DeviceIdentity }>;
}
const REASONS: ReadonlySet<string> = new Set(['hardware_swapped', 'imei_claimed']);
const FIELDS: ReadonlySet<string> = new Set(['imei', 'manufacturer', 'model', 'revision']);
const text = (value: unknown): string => (typeof value === 'string' ? value : '');
const boundedText = (value: unknown, maximum: number): string => {
const raw = text(value);
return raw.length > maximum ? raw.slice(0, maximum) : raw;
};
function reasons(value: unknown): readonly IdentityReason[] {
if (!Array.isArray(value)) return [];
return value.filter(
(item): item is IdentityReason => typeof item === 'string' && REASONS.has(item),
);
}
function conflicts(value: unknown): readonly IdentityConflict[] {
if (!Array.isArray(value)) return [];
const parsed: IdentityConflict[] = [];
for (const entry of value) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
const item = entry as Record<string, unknown>;
const reason = text(item.reason);
if (!REASONS.has(reason)) continue;
parsed.push({
reason: reason as IdentityReason,
instanceIds: Array.isArray(item.instanceIds)
? item.instanceIds.map(text).filter(Boolean)
: [],
instanceNames: Array.isArray(item.instanceNames)
? item.instanceNames.map(text).filter(Boolean)
: [],
});
}
return parsed;
}
function changes(value: unknown): readonly IdentityChange[] {
if (!Array.isArray(value)) return [];
const parsed: IdentityChange[] = [];
for (const entry of value) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
const item = entry as Record<string, unknown>;
const field = text(item.field);
if (!FIELDS.has(field)) continue;
parsed.push({
field: field as IdentityField,
from: boundedText(item.from, 64),
to: boundedText(item.to, 64),
});
}
return parsed;
}
function parseIdentity(value: unknown): DeviceIdentity | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const item = value as Record<string, unknown>;
const instanceId = boundedText(item.instanceId, 256);
if (!instanceId) return undefined;
return {
instanceId,
instanceName: boundedText(item.instanceName, 200),
status: item.status === 'pending' ? 'pending' : 'confirmed',
reasons: reasons(item.reasons),
conflicts: conflicts(item.conflicts),
imei: boundedText(item.imei, 64),
manufacturer: boundedText(item.manufacturer, 128),
model: boundedText(item.model, 128),
revision: boundedText(item.revision, 128),
agent: boundedText(item.agent, 128),
origin: boundedText(item.origin, 512),
fingerprint: boundedText(item.fingerprint, 128),
confirmedFingerprint: boundedText(item.confirmedFingerprint, 128),
changes: changes(item.changes),
observedAt: boundedText(item.observedAt, 64),
confirmedAt: boundedText(item.confirmedAt, 64),
};
}
async function json<T>(response: Response): Promise<T> {
if (!response.ok) throw new Error(`Identity request failed (${response.status})`);
return (await response.json()) as T;
}
const HEADERS = { accept: 'application/json' } as const;
function postJson(): RequestInit {
return {
method: 'POST',
credentials: 'same-origin',
headers: { ...HEADERS, 'content-type': 'application/json' },
body: '{}',
};
}
const path = (instanceId: string, action: string): string =>
`/api/v1/fleet/identities/${encodeURIComponent(instanceId)}/${action}`;
export function createIdentityApiDataSource(fetcher: typeof fetch = fetch): IdentityDataSource {
return {
async list(signal) {
const body = await json<unknown>(
await fetcher('/api/v1/fleet/identities', {
method: 'GET',
credentials: 'same-origin',
headers: HEADERS,
...(signal ? { signal } : {}),
}),
);
const source = (body ?? {}) as Record<string, unknown>;
const items = (Array.isArray(source.items) ? source.items : [])
.map(parseIdentity)
.filter((item): item is DeviceIdentity => item !== undefined);
const summary = (source.summary ?? {}) as Record<string, unknown>;
const count = (value: unknown): number =>
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
return { items, tracked: count(summary.tracked), pending: count(summary.pending) };
},
async confirm(instanceId) {
const body = await json<unknown>(await fetcher(path(instanceId, 'confirm'), postJson()));
const identity = parseIdentity((body as Record<string, unknown>).identity);
if (!identity) throw new Error('Identity response is invalid.');
return identity;
},
async refresh(instanceId) {
const body = await json<unknown>(await fetcher(path(instanceId, 'refresh'), postJson()));
const source = (body ?? {}) as Record<string, unknown>;
const identity = parseIdentity(source.identity);
return {
observed: source.observed === true,
...(identity ? { identity } : {}),
};
},
};
}