- 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.
187 lines
6.5 KiB
TypeScript
187 lines
6.5 KiB
TypeScript
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 } : {}),
|
|
};
|
|
},
|
|
};
|
|
}
|