Files
multi-simadmin/apps/web/src/instances/discovery-api-data-source.ts
T
chick a75badeb58 feat(web): rebuild the console around the fused control plane
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.
2026-09-05 18:53:04 +08:00

194 lines
6.7 KiB
TypeScript

export type DiscoveredDevice = Readonly<{
origin: string;
address: string;
port: number;
secure: boolean;
httpStatus: number;
identity: Readonly<Record<string, string>>;
knownInstanceId: string | null;
}>;
export type DiscoverySession = Readonly<{
sessionId: string;
status: 'scanning' | 'completed';
createdAt: string;
expiresAt: string;
scanned: number;
total: number;
ranges: readonly string[];
devices: readonly DiscoveredDevice[];
}>;
export type DeviceProbe = Readonly<{
origin: string;
reachable: boolean;
httpStatus: number | null;
identity: Readonly<Record<string, string>>;
knownInstanceId: string | null;
}>;
export interface DiscoveryDataSource {
start(signal?: AbortSignal): Promise<DiscoverySession>;
status(sessionId: string, signal?: AbortSignal): Promise<DiscoverySession>;
renew(sessionId: string, signal?: AbortSignal): Promise<DiscoverySession>;
stop(sessionId: string): Promise<void>;
probe(deviceUrl: string, signal?: AbortSignal): Promise<DeviceProbe>;
}
const BASE = '/api/v1/discovery';
const MAX_TEXT = 200;
const SESSION_ID = /^[0-9a-fA-F-]{1,64}$/u;
const ORIGIN = /^https?:\/\/[A-Za-z0-9.[\]:_-]{1,120}(?::\d{1,5})?$/u;
const IDENTITY_FIELDS: readonly string[] = [
'model',
'manufacturer',
'brand',
'firmware_version',
'os_version',
'version',
];
const record = (value: unknown): Record<string, unknown> | undefined =>
typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
const shortText = (value: unknown): value is string =>
typeof value === 'string' &&
value.length > 0 &&
value.length <= MAX_TEXT &&
!/[\x00-\x1F\x7F]/u.test(value);
const integer = (value: unknown, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): value is number =>
typeof value === 'number' && Number.isInteger(value) && value >= minimum && value <= maximum;
function identity(value: unknown): Readonly<Record<string, string>> {
const source = record(value);
if (!source) return {};
const result: Record<string, string> = {};
for (const field of IDENTITY_FIELDS) {
const entry = source[field];
if (shortText(entry)) result[field] = entry;
}
return result;
}
function device(value: unknown): DiscoveredDevice | undefined {
const source = record(value);
if (!source) return undefined;
const { origin, address, port, secure, httpStatus, knownInstanceId } = source;
if (!shortText(origin) || !ORIGIN.test(origin)) return undefined;
if (!shortText(address) || !integer(port, 1, 65_535) || typeof secure !== 'boolean')
return undefined;
if (!integer(httpStatus, 100, 599)) return undefined;
if (knownInstanceId !== null && !shortText(knownInstanceId)) return undefined;
return {
origin,
address,
port,
secure,
httpStatus,
identity: identity(source.identity),
knownInstanceId: typeof knownInstanceId === 'string' ? knownInstanceId : null,
};
}
function session(value: unknown): DiscoverySession {
const source = record(value);
const sessionId = source?.sessionId;
if (!source || !shortText(sessionId) || !SESSION_ID.test(sessionId))
throw new Error('Discovery response is invalid.');
const { status, createdAt, expiresAt, scanned, total, ranges } = source;
if (status !== 'scanning' && status !== 'completed')
throw new Error('Discovery response is invalid.');
if (!shortText(createdAt) || !shortText(expiresAt))
throw new Error('Discovery response is invalid.');
if (!integer(scanned) || !integer(total) || !Array.isArray(ranges))
throw new Error('Discovery response is invalid.');
const devices = ranges.every((entry) => shortText(entry)) ? source.devices : undefined;
if (!Array.isArray(devices)) throw new Error('Discovery response is invalid.');
const parsed = devices.map(device);
if (parsed.some((entry) => entry === undefined))
throw new Error('Discovery response is invalid.');
return {
sessionId,
status,
createdAt,
expiresAt,
scanned,
total,
ranges: ranges as string[],
devices: parsed as DiscoveredDevice[],
};
}
function probe(value: unknown): DeviceProbe {
const source = record(value);
if (!source) throw new Error('Discovery response is invalid.');
const { origin, reachable, httpStatus, knownInstanceId } = source;
if (!shortText(origin) || !ORIGIN.test(origin) || typeof reachable !== 'boolean')
throw new Error('Discovery response is invalid.');
if (httpStatus !== null && !integer(httpStatus, 100, 599))
throw new Error('Discovery response is invalid.');
if (knownInstanceId !== null && !shortText(knownInstanceId))
throw new Error('Discovery response is invalid.');
return {
origin,
reachable,
httpStatus: typeof httpStatus === 'number' ? httpStatus : null,
identity: identity(source.identity),
knownInstanceId: typeof knownInstanceId === 'string' ? knownInstanceId : null,
};
}
export function createDiscoveryApiDataSource(
fetcher: typeof fetch = globalThis.fetch,
): DiscoveryDataSource {
const request = async (
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
signal?: AbortSignal,
body?: unknown,
): Promise<unknown> => {
const response = await fetcher(`${BASE}${path}`, {
method,
credentials: 'same-origin',
headers:
body === undefined
? { accept: 'application/json' }
: { accept: 'application/json', 'content-type': 'application/json' },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
...(signal ? { signal } : {}),
});
if (!response.ok) throw new Error('Discovery request failed.');
if (response.status === 204) return undefined;
return (await response.json().catch(() => undefined)) as unknown;
};
const data = async (
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
signal?: AbortSignal,
body?: unknown,
): Promise<Record<string, unknown>> => {
const payload = record(await request(method, path, signal, body));
const envelope = payload ? record(payload.data) : undefined;
if (!envelope) throw new Error('Discovery response is invalid.');
return envelope;
};
return {
async start(signal) {
return session(await data('POST', '/sessions', signal, {}));
},
async status(sessionId, signal) {
return session(await data('GET', `/sessions/${encodeURIComponent(sessionId)}`, signal));
},
async renew(sessionId, signal) {
return session(await data('PUT', `/sessions/${encodeURIComponent(sessionId)}`, signal));
},
async stop(sessionId) {
await request('DELETE', `/sessions/${encodeURIComponent(sessionId)}`);
},
async probe(deviceUrl, signal) {
return probe(await data('POST', '/probe', signal, { device_url: deviceUrl }));
},
};
}