169 lines
5.9 KiB
TypeScript
169 lines
5.9 KiB
TypeScript
import type { InstanceService } from '../instances/instance-service.js';
|
|
import type {
|
|
InstanceSessionStore,
|
|
UpstreamResponse,
|
|
UpstreamSessionClientOptions,
|
|
} from '../connections/upstream-session-client.js';
|
|
|
|
export interface InstanceResources {
|
|
readonly cpuPercent?: number;
|
|
readonly memoryPercent?: number;
|
|
readonly maxTemperatureCelsius?: number;
|
|
readonly phoneNumbers?: readonly string[];
|
|
readonly version?: string;
|
|
readonly platform?: string;
|
|
}
|
|
|
|
const MAX_BODY_BYTES = 32_768;
|
|
const record = (value: unknown): Record<string, unknown> | undefined =>
|
|
value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: undefined;
|
|
const ranged = (value: unknown, minimum: number, maximum: number): number | undefined =>
|
|
typeof value === 'number' && Number.isFinite(value) && value >= minimum && value <= maximum
|
|
? value
|
|
: undefined;
|
|
|
|
function data(response: UpstreamResponse): Record<string, unknown> | undefined {
|
|
if (response.status < 200 || response.status >= 300) return undefined;
|
|
if (Buffer.byteLength(response.body, 'utf8') > MAX_BODY_BYTES) return undefined;
|
|
try {
|
|
return record(record(JSON.parse(response.body))?.data);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
const safeText = (value: unknown, maximum = 128): string | undefined =>
|
|
typeof value === 'string' &&
|
|
value.length > 0 &&
|
|
value.length <= maximum &&
|
|
!/[\u0000-\u001f\u007f]/u.test(value)
|
|
? value
|
|
: undefined;
|
|
|
|
export function parseHealth(response: UpstreamResponse): InstanceResources {
|
|
if (response.status < 200 || response.status >= 300) return {};
|
|
if (Buffer.byteLength(response.body, 'utf8') > MAX_BODY_BYTES) return {};
|
|
try {
|
|
const root = record(JSON.parse(response.body));
|
|
if (!root) return {};
|
|
const version = safeText(root.version);
|
|
const platform = safeText(root.platform);
|
|
return {
|
|
...(version ? { version } : {}),
|
|
...(platform ? { platform } : {}),
|
|
};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
export function parseStats(response: UpstreamResponse): InstanceResources {
|
|
const value = data(response);
|
|
if (!value) return {};
|
|
const cpuPercent = ranged(record(value.cpu_load)?.load_percent, 0, 100);
|
|
const memoryPercent = ranged(record(value.memory)?.used_percent, 0, 100);
|
|
const temperatures = Array.isArray(value.temperature)
|
|
? value.temperature
|
|
.map((item) => ranged(record(item)?.temperature, -100, 300))
|
|
.filter((item): item is number => item !== undefined)
|
|
: [];
|
|
const maxTemperatureCelsius = temperatures.length > 0 ? Math.max(...temperatures) : undefined;
|
|
const system = record(value.system);
|
|
const version =
|
|
safeText(value.version) ??
|
|
safeText(value.current_version) ??
|
|
safeText(system?.version) ??
|
|
safeText(system?.app_version);
|
|
const platform =
|
|
safeText(value.platform) ?? safeText(system?.platform) ?? safeText(system?.architecture);
|
|
return {
|
|
...(cpuPercent === undefined ? {} : { cpuPercent }),
|
|
...(memoryPercent === undefined ? {} : { memoryPercent }),
|
|
...(maxTemperatureCelsius === undefined ? {} : { maxTemperatureCelsius }),
|
|
...(version ? { version } : {}),
|
|
...(platform ? { platform } : {}),
|
|
};
|
|
}
|
|
|
|
export function parseSim(response: UpstreamResponse): InstanceResources {
|
|
const value = data(response);
|
|
if (!value || !Array.isArray(value.phone_numbers)) return {};
|
|
const phoneNumbers = value.phone_numbers.filter(
|
|
(item): item is string =>
|
|
typeof item === 'string' &&
|
|
item.length > 0 &&
|
|
item.length <= 32 &&
|
|
/^[+0-9 ()-]+$/.test(item),
|
|
);
|
|
return phoneNumbers.length > 0 ? { phoneNumbers: [...new Set(phoneNumbers)].slice(0, 16) } : {};
|
|
}
|
|
|
|
export class InstanceResourceService {
|
|
constructor(
|
|
private readonly options: {
|
|
readonly instances: InstanceService;
|
|
readonly sessions: InstanceSessionStore;
|
|
readonly request: UpstreamSessionClientOptions['request'];
|
|
readonly ensureSession?: (
|
|
instanceId: string,
|
|
origin: string,
|
|
force?: boolean,
|
|
) => Promise<void>;
|
|
},
|
|
) {}
|
|
|
|
async get(instanceId: string): Promise<InstanceResources> {
|
|
const [instance, session] = await Promise.all([
|
|
this.options.instances.get(instanceId),
|
|
Promise.resolve(this.options.sessions.sessionFor(instanceId)),
|
|
]);
|
|
if (!instance) return {};
|
|
if (session && session.origin !== instance.origin) return {};
|
|
if (!session && this.options.ensureSession) {
|
|
try {
|
|
await this.options.ensureSession(instanceId, instance.origin);
|
|
} catch {
|
|
// Passwordless and temporarily unavailable credentials still permit anonymous reads.
|
|
}
|
|
}
|
|
const paths = ['/api/stats', '/api/sim', '/api/health'] as const;
|
|
const readAll = () => {
|
|
const activeSession = this.options.sessions.sessionFor(instanceId);
|
|
return Promise.allSettled(
|
|
paths.map((path) =>
|
|
this.options.request({
|
|
url: `${instance.origin}${path}`,
|
|
method: 'GET',
|
|
headers: {
|
|
accept: 'application/json',
|
|
...(activeSession?.origin === instance.origin
|
|
? { cookie: activeSession.cookie }
|
|
: {}),
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
};
|
|
let results = await readAll();
|
|
const unauthorized = results.some(
|
|
(result) => result.status === 'fulfilled' && [401, 403].includes(result.value.status),
|
|
);
|
|
if (unauthorized && this.options.ensureSession) {
|
|
try {
|
|
await this.options.ensureSession(instanceId, instance.origin, true);
|
|
results = await readAll();
|
|
} catch {
|
|
// Return the safe partial result when re-authentication is unavailable.
|
|
}
|
|
}
|
|
const [stats, sim, health] = results;
|
|
return {
|
|
...(stats?.status === 'fulfilled' ? parseStats(stats.value) : {}),
|
|
...(sim?.status === 'fulfilled' ? parseSim(sim.value) : {}),
|
|
...(health?.status === 'fulfilled' ? parseHealth(health.value) : {}),
|
|
};
|
|
}
|
|
}
|