Render the Hub feature set in the existing Animal Island styling: fleet messages, notification centre, log centre, organisation panel, per-instance device module panels and the settings backup, connection and maintenance surfaces.
750 lines
30 KiB
TypeScript
750 lines
30 KiB
TypeScript
import type { AutomationSnapshot } from './automation-module.js';
|
|
import type { CallFeatureStatus, CallsSnapshot } from './calls-module.js';
|
|
import type { CellularSnapshot } from './cellular-module.js';
|
|
import type { DeviceNetworkSnapshot } from './device-network-module.js';
|
|
import type { EsimSafeLabel, EsimSnapshot } from './esim-module.js';
|
|
import type { NotificationsSnapshot } from './notifications-module.js';
|
|
import type { OtaSnapshot } from './ota-module.js';
|
|
import type { OverviewFieldValue, OverviewSnapshot } from './overview-system.js';
|
|
|
|
/**
|
|
* Bridge between the control-plane instance module proxy and the device panel modules.
|
|
*
|
|
* The API reads the device's own read-only endpoints, classifies each probe, and hands back a
|
|
* flat list of sections. This file is the single place that knows how those device sections map
|
|
* onto the console's presentation contracts, so every module renders live data instead of an
|
|
* empty shell.
|
|
*/
|
|
|
|
export type InstanceModuleState = 'ok' | 'empty' | 'auth-required' | 'unsupported' | 'failed';
|
|
|
|
export interface InstanceModuleSection {
|
|
readonly key: string;
|
|
readonly path: string;
|
|
readonly state: InstanceModuleState;
|
|
readonly status: number;
|
|
readonly data: unknown;
|
|
}
|
|
|
|
export interface InstanceModuleSnapshot {
|
|
readonly instanceId: string;
|
|
readonly module: string;
|
|
readonly observedAt: string;
|
|
readonly authenticated: boolean;
|
|
readonly sections: readonly InstanceModuleSection[];
|
|
}
|
|
|
|
export interface InstanceModuleReader {
|
|
read(instanceId: string, module: string, signal?: AbortSignal): Promise<InstanceModuleSnapshot>;
|
|
}
|
|
|
|
const MAX_ENTRIES = 40;
|
|
const MAX_STRING_LENGTH = 240;
|
|
|
|
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function parseSnapshot(value: unknown, instanceId: string, module: string): InstanceModuleSnapshot {
|
|
if (
|
|
!isRecord(value) ||
|
|
typeof value.instanceId !== 'string' ||
|
|
value.instanceId !== instanceId ||
|
|
typeof value.module !== 'string' ||
|
|
typeof value.observedAt !== 'string' ||
|
|
typeof value.authenticated !== 'boolean' ||
|
|
!Array.isArray(value.sections)
|
|
)
|
|
throw new Error('服务端返回的模块快照无效。');
|
|
const sections = value.sections.slice(0, 64).flatMap((entry): InstanceModuleSection[] => {
|
|
if (!isRecord(entry) || typeof entry.key !== 'string' || typeof entry.path !== 'string')
|
|
return [];
|
|
const state = entry.state;
|
|
return [
|
|
{
|
|
key: entry.key.slice(0, 64),
|
|
path: entry.path.slice(0, 256),
|
|
state:
|
|
state === 'ok' ||
|
|
state === 'empty' ||
|
|
state === 'auth-required' ||
|
|
state === 'unsupported' ||
|
|
state === 'failed'
|
|
? state
|
|
: 'failed',
|
|
status:
|
|
typeof entry.status === 'number' && Number.isFinite(entry.status) ? entry.status : 0,
|
|
data: entry.data,
|
|
},
|
|
];
|
|
});
|
|
return {
|
|
instanceId: value.instanceId,
|
|
module: value.module || module,
|
|
observedAt: value.observedAt,
|
|
authenticated: value.authenticated,
|
|
sections,
|
|
};
|
|
}
|
|
|
|
export function createInstanceModuleApiReader(fetcher: typeof fetch = fetch): InstanceModuleReader {
|
|
return {
|
|
async read(instanceId, module, signal) {
|
|
const url = `/api/v1/instances/${encodeURIComponent(instanceId)}/modules/${encodeURIComponent(
|
|
module,
|
|
)}`;
|
|
const response = await fetcher(url, {
|
|
method: 'GET',
|
|
credentials: 'same-origin',
|
|
headers: { accept: 'application/json' },
|
|
...(signal ? { signal } : {}),
|
|
});
|
|
if (!response.ok) throw new Error(`请求失败 (${response.status})`);
|
|
return parseSnapshot(await response.json(), instanceId, module);
|
|
},
|
|
};
|
|
}
|
|
|
|
function section(snapshot: InstanceModuleSnapshot, key: string): InstanceModuleSection | undefined {
|
|
return snapshot.sections.find((candidate) => candidate.key === key);
|
|
}
|
|
|
|
function sectionRecord(
|
|
snapshot: InstanceModuleSnapshot,
|
|
key: string,
|
|
): Readonly<Record<string, unknown>> {
|
|
const entry = section(snapshot, key);
|
|
if (!entry) return {};
|
|
if (isRecord(entry.data)) return entry.data;
|
|
if (Array.isArray(entry.data)) return { items: entry.data };
|
|
return {};
|
|
}
|
|
|
|
/** First present value among candidate device field names; device payloads are not uniform. */
|
|
function pick(source: Readonly<Record<string, unknown>>, keys: readonly string[]): unknown {
|
|
for (const key of keys) {
|
|
const value = source[key];
|
|
if (value !== undefined && value !== null) return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function text(value: unknown): string | undefined {
|
|
if (typeof value === 'string')
|
|
return value.length > MAX_STRING_LENGTH ? `${value.slice(0, MAX_STRING_LENGTH)}…` : value;
|
|
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
|
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
return undefined;
|
|
}
|
|
|
|
function flag(value: unknown): boolean | undefined {
|
|
if (typeof value === 'boolean') return value;
|
|
if (typeof value === 'number') return value !== 0;
|
|
if (typeof value === 'string') {
|
|
const lowered = value.toLocaleLowerCase();
|
|
if (['true', 'yes', 'on', 'enabled', '1'].includes(lowered)) return true;
|
|
if (['false', 'no', 'off', 'disabled', '0'].includes(lowered)) return false;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function count(value: unknown): number | undefined {
|
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
if (typeof value === 'string') {
|
|
const parsed = Number(value);
|
|
if (Number.isFinite(parsed)) return parsed;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** Keep only display-safe primitives, preserving device key names for the label table. */
|
|
function primitives(source: Readonly<Record<string, unknown>>): Record<string, OverviewFieldValue> {
|
|
const result: Record<string, OverviewFieldValue> = {};
|
|
for (const [key, value] of Object.entries(source)) {
|
|
if (Object.keys(result).length >= MAX_ENTRIES) break;
|
|
if (typeof value === 'string') {
|
|
result[key] = text(value) ?? null;
|
|
} else if (typeof value === 'number') {
|
|
result[key] = Number.isFinite(value) ? value : null;
|
|
} else if (typeof value === 'boolean') {
|
|
result[key] = value;
|
|
} else if (Array.isArray(value)) {
|
|
const entries = value
|
|
.slice(0, 6)
|
|
.map((item) => (isRecord(item) ? text(pick(item, ['name', 'label', 'value'])) : text(item)))
|
|
.filter((item): item is string => item !== undefined);
|
|
result[key] = entries.length ? entries.join('、') : null;
|
|
} else {
|
|
result[key] = null;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function arrayFrom(
|
|
source: Readonly<Record<string, unknown>>,
|
|
keys: readonly string[],
|
|
): readonly unknown[] {
|
|
for (const key of keys) {
|
|
const value = source[key];
|
|
if (Array.isArray(value)) return value.slice(0, MAX_ENTRIES);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function tally(items: readonly unknown[], predicate: (item: unknown) => boolean): number {
|
|
return items.reduce<number>((total, item) => (predicate(item) ? total + 1 : total), 0);
|
|
}
|
|
|
|
function statusOf(items: readonly unknown[]): 'healthy' | 'degraded' | 'failed' | 'idle' {
|
|
if (items.length === 0) return 'idle';
|
|
const failed = tally(items, (item) => {
|
|
const record = isRecord(item) ? item : {};
|
|
const value = text(pick(record, ['status', 'state', 'result']))?.toLocaleLowerCase();
|
|
return value === 'failed' || value === 'error';
|
|
});
|
|
if (failed === 0) return 'healthy';
|
|
return failed >= items.length ? 'failed' : 'degraded';
|
|
}
|
|
|
|
function observed(snapshot: InstanceModuleSnapshot): { readonly observedAt: string } {
|
|
return { observedAt: snapshot.observedAt };
|
|
}
|
|
|
|
export function mapOverview(snapshot: InstanceModuleSnapshot): OverviewSnapshot {
|
|
const stats = sectionRecord(snapshot, 'stats');
|
|
// Anchored on word boundaries: "download_bytes" contains "load" but is throughput, not a load average.
|
|
const cpuKeys = Object.keys(stats).filter((key) =>
|
|
/(^|_)(cpu|memory|mem|temp|thermal|load)(_|$)/u.test(key),
|
|
);
|
|
const cpu: Record<string, OverviewFieldValue> = {};
|
|
const rest: Record<string, OverviewFieldValue> = {};
|
|
for (const [key, value] of Object.entries(primitives(stats))) {
|
|
if (cpuKeys.includes(key)) cpu[key] = value;
|
|
else rest[key] = value;
|
|
}
|
|
// /stats/cpu is a separate probe; its core summary belongs beside the load averages.
|
|
Object.assign(cpu, primitives(sectionRecord(snapshot, 'cpu')));
|
|
// Message counters read better beside the traffic totals than in their own card.
|
|
Object.assign(rest, primitives(sectionRecord(snapshot, 'smsStats')));
|
|
return {
|
|
...observed(snapshot),
|
|
device: primitives(sectionRecord(snapshot, 'device')),
|
|
sim: primitives(sectionRecord(snapshot, 'sim')),
|
|
network: {
|
|
...primitives(sectionRecord(snapshot, 'network')),
|
|
...primitives(sectionRecord(snapshot, 'data')),
|
|
},
|
|
stats: rest,
|
|
cpu,
|
|
connectivity: primitives(sectionRecord(snapshot, 'connectivity')),
|
|
};
|
|
}
|
|
|
|
export function mapCellular(snapshot: InstanceModuleSnapshot): CellularSnapshot {
|
|
const network = sectionRecord(snapshot, 'network');
|
|
const signal = sectionRecord(snapshot, 'signalStrength');
|
|
const cells = isRecord(sectionRecord(snapshot, 'cells').cells)
|
|
? (sectionRecord(snapshot, 'cells').cells as Readonly<Record<string, unknown>>)
|
|
: sectionRecord(snapshot, 'cells');
|
|
const location = sectionRecord(snapshot, 'cellLocation');
|
|
const operators = sectionRecord(snapshot, 'operators');
|
|
const available = arrayFrom(operators, ['operators', 'available', 'items', 'list']).flatMap(
|
|
(item): string[] => {
|
|
const name = isRecord(item) ? text(pick(item, ['name', 'long_name', 'alpha'])) : text(item);
|
|
return name ? [name] : [];
|
|
},
|
|
);
|
|
const roaming = flag(pick(network, ['roaming', 'is_roaming', 'roaming_active']))
|
|
? 'roaming'
|
|
: text(pick(sectionRecord(snapshot, 'roaming'), ['state', 'status', 'roaming']));
|
|
return {
|
|
...observed(snapshot),
|
|
networkRegistration: {
|
|
state: text(pick(network, ['registration_state', 'register_state', 'state', 'status'])),
|
|
mode: text(pick(network, ['radio_mode', 'mode', 'network_mode', 'preferred_mode'])),
|
|
operator: text(pick(network, ['operator_name', 'operator', 'carrier', 'name'])),
|
|
roaming,
|
|
},
|
|
signal: {
|
|
rssi: text(pick(signal, ['rssi', 'signal_dbm', 'dbm', 'strength'])),
|
|
rsrp: text(pick(signal, ['rsrp', 'lte_rsrp', 'nr_rsrp'])),
|
|
rsrq: text(pick(signal, ['rsrq', 'lte_rsrq'])),
|
|
sinr: text(pick(signal, ['sinr', 'lte_sinr', 'nr_sinr'])),
|
|
quality: text(pick(signal, ['signal_percent', 'level', 'quality', 'asu'])),
|
|
},
|
|
cellsLocation: {
|
|
cell: text(pick(cells, ['cell_id', 'cellId', 'eci', 'cid', 'nr_cell_id'])),
|
|
area: text(pick(cells, ['tac', 'tracking_area', 'area_code', 'rac'])),
|
|
technology: text(
|
|
pick(cells, ['network_type', 'technology', 'rat']) ??
|
|
pick(network, ['network_type', 'technology', 'rat']),
|
|
),
|
|
latitude: text(pick(location, ['latitude', 'lat'])),
|
|
longitude: text(pick(location, ['longitude', 'lng', 'lon'])),
|
|
mcc: text(pick(location, ['mcc']) ?? pick(cells, ['mcc'])),
|
|
mnc: text(pick(location, ['mnc']) ?? pick(cells, ['mnc'])),
|
|
pci: text(pick(location, ['pci']) ?? pick(cells, ['pci', 'phys_cell_id'])),
|
|
arfcn: text(
|
|
pick(location, ['earfcn', 'arfcn', 'nr_arfcn']) ??
|
|
pick(cells, ['earfcn', 'arfcn', 'nr_arfcn']),
|
|
),
|
|
},
|
|
operators: {
|
|
current: text(pick(network, ['operator_name', 'operator', 'carrier'])),
|
|
available: available.length ? available.slice(0, 12).join('、') : undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function mapDeviceNetwork(snapshot: InstanceModuleSnapshot): DeviceNetworkSnapshot {
|
|
const wlanStatus = sectionRecord(snapshot, 'wlanStatus');
|
|
const wlanProfiles = sectionRecord(snapshot, 'wlanProfiles');
|
|
const interfaces = sectionRecord(snapshot, 'interfaces');
|
|
const addresses = sectionRecord(snapshot, 'addresses');
|
|
const ddnsStatus = sectionRecord(snapshot, 'ddnsStatus');
|
|
const ddnsConfig = sectionRecord(snapshot, 'ddnsConfig');
|
|
const ddnsLogs = sectionRecord(snapshot, 'ddnsLogs');
|
|
const profileItems = arrayFrom(wlanProfiles, ['profiles', 'items', 'list', 'networks']);
|
|
const interfaceItems = arrayFrom(interfaces, ['interfaces', 'items', 'list']);
|
|
const logItems = arrayFrom(ddnsLogs, ['logs', 'entries', 'items']);
|
|
const addressList = (keys: readonly string[]): readonly string[] =>
|
|
arrayFrom(addresses, keys)
|
|
.map((item) => text(item))
|
|
.filter((item): item is string => item !== undefined)
|
|
.slice(0, 8);
|
|
const ipv4 = addressList(['ipv4', 'ipv4_addresses', 'v4']);
|
|
const ipv6 = addressList(['ipv6', 'ipv6_addresses', 'v6']);
|
|
return {
|
|
...observed(snapshot),
|
|
connectionAddresses: { ipv4, ipv6 },
|
|
wlan: {
|
|
status: {
|
|
enabled: flag(pick(wlanStatus, ['enabled', 'wlan_enabled', 'is_enabled'])),
|
|
radioState: text(pick(wlanStatus, ['radio_state', 'radioState', 'state'])),
|
|
connectionState: text(
|
|
pick(wlanStatus, ['connection_state', 'connectionState', 'status', 'state']),
|
|
),
|
|
activeProfile: text(pick(wlanStatus, ['active_profile', 'activeProfile', 'profile'])),
|
|
ssid: text(pick(wlanStatus, ['ssid', 'network_name'])),
|
|
},
|
|
profiles: profileItems.flatMap((item) =>
|
|
isRecord(item)
|
|
? [
|
|
{
|
|
name: text(pick(item, ['name', 'profile_name'])),
|
|
ssid: text(pick(item, ['ssid'])),
|
|
security: text(pick(item, ['security', 'key_management', 'encryption'])),
|
|
enabled: flag(pick(item, ['enabled', 'active'])),
|
|
priority: count(pick(item, ['priority', 'order'])),
|
|
},
|
|
]
|
|
: [],
|
|
),
|
|
},
|
|
interfaces: interfaceItems.flatMap((item) =>
|
|
isRecord(item)
|
|
? [
|
|
{
|
|
name: text(pick(item, ['name', 'interface', 'ifname'])),
|
|
kind: text(pick(item, ['kind', 'type', 'driver'])),
|
|
state: text(pick(item, ['state', 'status', 'operstate'])),
|
|
macAddress: text(pick(item, ['mac_address', 'macAddress', 'mac'])),
|
|
mtu: count(pick(item, ['mtu'])),
|
|
addresses: arrayFrom(item, ['addresses', 'ips']).flatMap((address) =>
|
|
isRecord(address)
|
|
? [
|
|
{
|
|
family: text(pick(address, ['family', 'scope'])),
|
|
address: text(pick(address, ['address', 'ip', 'value'])),
|
|
prefixLength: count(pick(address, ['prefix_length', 'prefix', 'cidr'])),
|
|
scope: text(pick(address, ['scope'])),
|
|
},
|
|
]
|
|
: typeof address === 'string'
|
|
? [{ address, family: undefined, prefixLength: undefined, scope: undefined }]
|
|
: [],
|
|
),
|
|
},
|
|
]
|
|
: [],
|
|
),
|
|
ddns: {
|
|
status: {
|
|
enabled: flag(pick(ddnsStatus, ['enabled', 'configured'])),
|
|
state: text(pick(ddnsStatus, ['state', 'status', 'last_result'])),
|
|
lastUpdateAt: text(pick(ddnsStatus, ['last_update_at', 'lastUpdateAt', 'updated_at'])),
|
|
},
|
|
config: {
|
|
provider: text(pick(ddnsConfig, ['provider', 'service'])),
|
|
hostname: text(pick(ddnsConfig, ['hostname', 'domain'])),
|
|
updateIntervalSeconds: count(
|
|
pick(ddnsConfig, ['update_interval_seconds', 'interval_seconds', 'interval']),
|
|
),
|
|
},
|
|
logSummary: {
|
|
totalEntries: count(pick(ddnsLogs, ['total', 'total_entries', 'count'])) ?? logItems.length,
|
|
successfulUpdates:
|
|
count(pick(ddnsLogs, ['successful', 'success', 'successful_updates'])) ??
|
|
tally(logItems, (item) => {
|
|
const value = text(
|
|
pick(isRecord(item) ? item : {}, ['status', 'result', 'state']),
|
|
)?.toLocaleLowerCase();
|
|
return value === 'success' || value === 'ok' || value === 'updated';
|
|
}),
|
|
failedUpdates:
|
|
count(pick(ddnsLogs, ['failed', 'failures', 'failed_updates'])) ??
|
|
tally(logItems, (item) => {
|
|
const value = text(
|
|
pick(isRecord(item) ? item : {}, ['status', 'result', 'state']),
|
|
)?.toLocaleLowerCase();
|
|
return value === 'failed' || value === 'error';
|
|
}),
|
|
lastEventAt: text(pick(ddnsLogs, ['last_event_at', 'latest', 'last_at'])),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
export function mapEsim(snapshot: InstanceModuleSnapshot): EsimSnapshot {
|
|
const profiles = sectionRecord(snapshot, 'profiles');
|
|
const euicc = sectionRecord(snapshot, 'euicc');
|
|
const lpac = sectionRecord(snapshot, 'lpacStatus');
|
|
const items = arrayFrom(profiles, ['profiles', 'list', 'items', 'data']).flatMap((item) =>
|
|
isRecord(item) ? [item] : [],
|
|
);
|
|
const enabled = tally(items, (item) => {
|
|
const record = isRecord(item) ? item : {};
|
|
return (
|
|
flag(pick(record, ['enabled', 'is_enabled'])) === true ||
|
|
text(pick(record, ['status', 'state']))?.toLocaleLowerCase() === 'enabled'
|
|
);
|
|
});
|
|
const lpacValue = text(
|
|
pick(lpac, ['status', 'state', 'available']) ?? pick(euicc, ['lpac_status', 'status']),
|
|
)?.toLocaleLowerCase();
|
|
const workModeValue = text(pick(euicc, ['work_mode', 'workMode']))?.toLocaleLowerCase();
|
|
const labels: EsimSafeLabel[] = [];
|
|
for (const item of items.slice(0, 8)) {
|
|
const record = isRecord(item) ? item : {};
|
|
const enabled = flag(pick(record, ['enabled', 'is_enabled']));
|
|
const itemStatus = text(pick(record, ['status', 'state']))?.toLocaleLowerCase();
|
|
const label: EsimSafeLabel =
|
|
itemStatus === 'error' || itemStatus === 'failed'
|
|
? 'Error'
|
|
: itemStatus === 'pending' || itemStatus === 'downloading'
|
|
? 'Pending'
|
|
: enabled === true || itemStatus === 'enabled'
|
|
? 'Enabled'
|
|
: enabled === false || itemStatus === 'disabled'
|
|
? 'Disabled'
|
|
: 'Provisioned';
|
|
if (!labels.includes(label)) labels.push(label);
|
|
}
|
|
return {
|
|
profileCount: items.length,
|
|
enabledProfileCount: enabled,
|
|
lpacStatus:
|
|
lpacValue === 'available' || lpacValue === 'unavailable' || lpacValue === 'degraded'
|
|
? lpacValue
|
|
: lpacValue === 'true' || flag(pick(lpac, ['available', 'supported'])) === true
|
|
? 'available'
|
|
: 'unknown',
|
|
workMode:
|
|
workModeValue === 'idle' || workModeValue === 'working' || workModeValue === 'disabled'
|
|
? workModeValue
|
|
: 'unknown',
|
|
labels,
|
|
};
|
|
}
|
|
|
|
/** Call capabilities the Hub surfaces as separate panels; each maps to one probe. */
|
|
const CALL_FEATURE_PROBES: readonly { readonly key: string; readonly name: string }[] = [
|
|
{ key: 'forwarding', name: '呼叫转移' },
|
|
{ key: 'volume', name: '通话音量' },
|
|
{ key: 'voicemail', name: '语音信箱' },
|
|
{ key: 'ims', name: 'IMS 语音' },
|
|
];
|
|
|
|
export function mapCalls(snapshot: InstanceModuleSnapshot): CallsSnapshot {
|
|
const calls = sectionRecord(snapshot, 'calls');
|
|
const history = sectionRecord(snapshot, 'history');
|
|
const settings = sectionRecord(snapshot, 'settings');
|
|
const active =
|
|
count(pick(calls, ['active', 'active_calls', 'current'])) ??
|
|
tally(arrayFrom(calls, ['calls', 'items', 'data', 'list']), (item) => {
|
|
const value = text(
|
|
pick(isRecord(item) ? item : {}, ['state', 'status']),
|
|
)?.toLocaleLowerCase();
|
|
return value === 'active' || value === 'dialing' || value === 'ringing';
|
|
});
|
|
const historyItems = arrayFrom(history, ['calls', 'items', 'history', 'list', 'data']);
|
|
return {
|
|
...observed(snapshot),
|
|
calls: {
|
|
state: text(pick(calls, ['state', 'status', 'call_status'])),
|
|
total: count(pick(calls, ['total', 'count'])) ?? historyItems.length,
|
|
active,
|
|
ringing: count(pick(calls, ['ringing', 'incoming'])),
|
|
held: count(pick(calls, ['held', 'hold'])),
|
|
failed: count(pick(calls, ['failed', 'error_count'])),
|
|
},
|
|
devices: {
|
|
state: text(pick(settings, ['state', 'status'])) ?? text(pick(calls, ['state', 'status'])),
|
|
total: count(pick(settings, ['total', 'line_count', 'slots'])),
|
|
online: count(pick(settings, ['online'])),
|
|
offline: count(pick(settings, ['offline'])),
|
|
busy: active && active > 0 ? active : count(pick(settings, ['busy'])),
|
|
},
|
|
features: CALL_FEATURE_PROBES.flatMap(({ key, name }): CallFeatureStatus[] => {
|
|
const entry = section(snapshot, key);
|
|
// A probe the firmware never answered is unknown; one that answered is a live verdict.
|
|
if (!entry || entry.state === 'failed') return [];
|
|
if (entry.state === 'unsupported' || entry.state === 'auth-required')
|
|
return [{ name, state: 'unavailable' as const }];
|
|
const message = isRecord(entry.data) ? text(entry.data['message']) : undefined;
|
|
return [{ name, state: 'available' as const, ...(message ? { detail: message } : {}) }];
|
|
}),
|
|
};
|
|
}
|
|
|
|
export function mapNotifications(snapshot: InstanceModuleSnapshot): NotificationsSnapshot {
|
|
const config = sectionRecord(snapshot, 'config');
|
|
const queue = sectionRecord(snapshot, 'queue');
|
|
const logs = sectionRecord(snapshot, 'logs');
|
|
const channels = arrayFrom(config, ['channels', 'items', 'list', 'webhooks']);
|
|
const queueItems = arrayFrom(queue, ['items', 'jobs', 'queue', 'notifications', 'data']);
|
|
const logItems = arrayFrom(logs, ['items', 'logs', 'entries', 'data']);
|
|
const enabledChannels = tally(channels, (item) => {
|
|
const record = isRecord(item) ? item : {};
|
|
return flag(pick(record, ['enabled', 'active'])) !== false;
|
|
});
|
|
return {
|
|
...observed(snapshot),
|
|
channels: {
|
|
status:
|
|
flag(pick(config, ['enabled'])) === false
|
|
? 'unavailable'
|
|
: enabledChannels === 0 && channels.length > 0
|
|
? 'unavailable'
|
|
: enabledChannels < channels.length
|
|
? 'degraded'
|
|
: statusOf(channels),
|
|
total: count(pick(config, ['total', 'count'])) ?? channels.length,
|
|
enabled: enabledChannels,
|
|
disabled: Math.max(
|
|
0,
|
|
(count(pick(config, ['total', 'count'])) ?? channels.length) - enabledChannels,
|
|
),
|
|
},
|
|
queue: {
|
|
status: statusOf(queueItems),
|
|
total: count(pick(queue, ['total', 'count'])) ?? queueItems.length,
|
|
pending:
|
|
count(pick(queue, ['pending', 'queued'])) ??
|
|
tally(queueItems, (item) => {
|
|
const value = text(pick(isRecord(item) ? item : {}, ['status', 'state']))
|
|
?.toLocaleLowerCase()
|
|
.trim();
|
|
return value === 'pending' || value === 'queued';
|
|
}),
|
|
processing:
|
|
count(pick(queue, ['processing', 'in_progress'])) ??
|
|
tally(queueItems, (item) => {
|
|
const value = text(pick(isRecord(item) ? item : {}, ['status', 'state']))
|
|
?.toLocaleLowerCase()
|
|
.trim();
|
|
return value === 'processing' || value === 'sending';
|
|
}),
|
|
delivered:
|
|
count(pick(queue, ['delivered', 'sent', 'succeeded'])) ??
|
|
tally(queueItems, (item) => {
|
|
const value = text(pick(isRecord(item) ? item : {}, ['status', 'state']))
|
|
?.toLocaleLowerCase()
|
|
.trim();
|
|
return value === 'delivered' || value === 'sent' || value === 'success';
|
|
}),
|
|
failed:
|
|
count(pick(queue, ['failed', 'errors'])) ??
|
|
tally(queueItems, (item) => {
|
|
const value = text(pick(isRecord(item) ? item : {}, ['status', 'state']))
|
|
?.toLocaleLowerCase()
|
|
.trim();
|
|
return value === 'failed' || value === 'error';
|
|
}),
|
|
},
|
|
logs: {
|
|
status: statusOf(logItems),
|
|
total: count(pick(logs, ['total', 'count'])) ?? logItems.length,
|
|
info: tally(logItems, (item) => {
|
|
const value = text(pick(isRecord(item) ? item : {}, ['level']))?.toLocaleLowerCase();
|
|
return value === 'info' || value === 'debug';
|
|
}),
|
|
warning: tally(logItems, (item) => {
|
|
const value = text(pick(isRecord(item) ? item : {}, ['level']))?.toLocaleLowerCase();
|
|
return value === 'warn' || value === 'warning';
|
|
}),
|
|
error: tally(logItems, (item) => {
|
|
const value = text(pick(isRecord(item) ? item : {}, ['level']))?.toLocaleLowerCase();
|
|
return value === 'error' || value === 'fatal';
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function mapAutomation(snapshot: InstanceModuleSnapshot): AutomationSnapshot {
|
|
const config = sectionRecord(snapshot, 'config');
|
|
const logs = sectionRecord(snapshot, 'logs');
|
|
const rules = arrayFrom(config, ['rules', 'tasks', 'items', 'list', 'jobs']);
|
|
const logItems = arrayFrom(logs, ['items', 'executions', 'logs', 'entries']);
|
|
const enabledRules = tally(
|
|
rules,
|
|
(item) => flag(pick(isRecord(item) ? item : {}, ['enabled'])) !== false,
|
|
);
|
|
const failedRuns = tally(logItems, (item) => {
|
|
const value = text(
|
|
pick(isRecord(item) ? item : {}, ['status', 'result', 'state']),
|
|
)?.toLocaleLowerCase();
|
|
return value === 'failed' || value === 'error';
|
|
});
|
|
const scheduler = text(pick(config, ['scheduler', 'scheduler_state']))?.toLocaleLowerCase();
|
|
return {
|
|
...observed(snapshot),
|
|
status: {
|
|
state:
|
|
flag(pick(config, ['enabled'])) === false
|
|
? 'disabled'
|
|
: failedRuns > 0
|
|
? 'degraded'
|
|
: logItems.length || rules.length
|
|
? 'healthy'
|
|
: 'unknown',
|
|
scheduler:
|
|
scheduler === 'active' ||
|
|
scheduler === 'idle' ||
|
|
scheduler === 'paused' ||
|
|
scheduler === 'disabled' ||
|
|
scheduler === 'unavailable'
|
|
? scheduler
|
|
: flag(pick(config, ['enabled'])) === false
|
|
? 'disabled'
|
|
: rules.length
|
|
? 'active'
|
|
: 'idle',
|
|
workers:
|
|
flag(pick(config, ['enabled'])) === false
|
|
? 'disabled'
|
|
: failedRuns > 0
|
|
? 'degraded'
|
|
: logItems.length
|
|
? 'available'
|
|
: 'unavailable',
|
|
},
|
|
tasks: {
|
|
total: count(pick(config, ['total', 'count'])) ?? rules.length,
|
|
enabled: enabledRules,
|
|
disabled: Math.max(
|
|
0,
|
|
(count(pick(config, ['total', 'count'])) ?? rules.length) - enabledRules,
|
|
),
|
|
running: count(pick(config, ['running'])),
|
|
queued: count(pick(config, ['queued'])),
|
|
succeeded: tally(logItems, (item) => {
|
|
const value = text(
|
|
pick(isRecord(item) ? item : {}, ['status', 'result', 'state']),
|
|
)?.toLocaleLowerCase();
|
|
return value === 'success' || value === 'ok' || value === 'completed';
|
|
}),
|
|
failed: failedRuns,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function mapOta(snapshot: InstanceModuleSnapshot): OtaSnapshot {
|
|
const status = sectionRecord(snapshot, 'status');
|
|
const state = text(pick(status, ['status', 'state', 'phase']))?.toLocaleLowerCase();
|
|
const progress = count(pick(status, ['progress_percent', 'progressPercent', 'progress']));
|
|
const available = flag(pick(status, ['update_available', 'updateAvailable', 'available']));
|
|
return {
|
|
currentVersion: text(pick(status, ['current_version', 'currentVersion', 'version'])),
|
|
status:
|
|
state === 'idle' ||
|
|
state === 'checking' ||
|
|
state === 'up-to-date' ||
|
|
state === 'available' ||
|
|
state === 'downloading' ||
|
|
state === 'verifying' ||
|
|
state === 'installing' ||
|
|
state === 'rebooting' ||
|
|
state === 'completed' ||
|
|
state === 'failed'
|
|
? state
|
|
: 'unknown',
|
|
progressPercent: progress === undefined ? undefined : Math.max(0, Math.min(100, progress)),
|
|
updateAvailable: available ?? state === 'available',
|
|
};
|
|
}
|
|
|
|
export interface InstanceModuleDataSources {
|
|
readonly overview: { load(instanceId: string, signal: AbortSignal): Promise<OverviewSnapshot> };
|
|
readonly cellular: { load(instanceId: string, signal: AbortSignal): Promise<CellularSnapshot> };
|
|
readonly deviceNetwork: {
|
|
load(instanceId: string, signal: AbortSignal): Promise<DeviceNetworkSnapshot>;
|
|
};
|
|
readonly esim: { load(instanceId: string, signal: AbortSignal): Promise<unknown> };
|
|
readonly calls: { load(instanceId: string, signal: AbortSignal): Promise<CallsSnapshot> };
|
|
readonly notifications: {
|
|
load(instanceId: string, signal: AbortSignal): Promise<unknown>;
|
|
};
|
|
readonly automation: { load(instanceId: string, signal: AbortSignal): Promise<unknown> };
|
|
readonly ota: { load(instanceId: string, signal: AbortSignal): Promise<unknown> };
|
|
}
|
|
|
|
/**
|
|
* One reader, one in-flight cache per module, eight module contracts. The device panel and the
|
|
* fleet drill-down share this so a tab switch never re-probes a device that was just read.
|
|
*/
|
|
export function createInstanceModuleDataSources(
|
|
reader: InstanceModuleReader = createInstanceModuleApiReader(),
|
|
ttlMs = 15_000,
|
|
): InstanceModuleDataSources {
|
|
const cache = new Map<string, { at: number; value: InstanceModuleSnapshot }>();
|
|
const inflight = new Map<string, Promise<InstanceModuleSnapshot>>();
|
|
const read = (module: string) => (instanceId: string, signal: AbortSignal) => {
|
|
const key = `${instanceId}:${module}`;
|
|
const cached = cache.get(key);
|
|
if (cached && Date.now() - cached.at < ttlMs) return Promise.resolve(cached.value);
|
|
const pending = inflight.get(key);
|
|
if (pending) return pending;
|
|
const next = reader.read(instanceId, module, signal).then(
|
|
(value) => {
|
|
cache.set(key, { at: Date.now(), value });
|
|
inflight.delete(key);
|
|
return value;
|
|
},
|
|
(error: unknown) => {
|
|
inflight.delete(key);
|
|
throw error;
|
|
},
|
|
);
|
|
inflight.set(key, next);
|
|
return next;
|
|
};
|
|
return {
|
|
overview: { load: async (id, signal) => mapOverview(await read('overview')(id, signal)) },
|
|
cellular: { load: async (id, signal) => mapCellular(await read('cellular')(id, signal)) },
|
|
deviceNetwork: {
|
|
load: async (id, signal) => mapDeviceNetwork(await read('device-network')(id, signal)),
|
|
},
|
|
esim: { load: async (id, signal) => mapEsim(await read('esim')(id, signal)) },
|
|
calls: { load: async (id, signal) => mapCalls(await read('calls')(id, signal)) },
|
|
notifications: {
|
|
load: async (id, signal) => mapNotifications(await read('notifications')(id, signal)),
|
|
},
|
|
automation: {
|
|
load: async (id, signal) => mapAutomation(await read('automation')(id, signal)),
|
|
},
|
|
ota: { load: async (id, signal) => mapOta(await read('ota')(id, signal)) },
|
|
};
|
|
}
|