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:
@@ -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 } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user