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.
194 lines
6.7 KiB
TypeScript
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 }));
|
|
},
|
|
};
|
|
}
|