Files
telemt-api/web/src/lib/api/client.ts
T
Denozordec cb6ecd46b1
Publish telemt-api gateway Docker image / test (push) Successful in 32s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m28s
Refactor Mihomo WebSocket URL handling and improve Svelte data processing
- Updated the `mihomoWsUrl` function to handle cases where the gateway base URL may be undefined, ensuring robust WebSocket URL generation.
- Enhanced the Svelte component to read and process memory metrics more reliably by implementing a mechanism to capture multiple lines of data before concluding the read operation, improving data accuracy.
2026-03-31 09:26:59 +07:00

360 lines
13 KiB
TypeScript

import { PUBLIC_TELEMT_GATEWAY_URL } from '$env/static/public';
import type { components } from './aggregate.gen.js';
import type { SummaryData } from './summary-types.js';
import type {
StatsSummaryData,
TelemtErrorBody,
TelemtSuccess,
UserInfo
} from './telemt-v1.js';
import type { MihomoMetaResponse } from './mihomo-types.js';
export function gatewayBase(): string {
const u = PUBLIC_TELEMT_GATEWAY_URL || '';
return u.replace(/\/$/, '');
}
/** Путь к Mihomo external-controller через шлюз: без ведущего слэша. */
export function mihomoUrl(alias: string, path: string): string {
const p = path.replace(/^\/+/, '');
return `${gatewayBase()}/api/${encodeURIComponent(alias)}/mihomo/${p}`;
}
/**
* WebSocket к шлюзу: `ws(s)://…/api/{alias}/mihomo/{path}`.
* Authorization к Mihomo подставляет шлюз; браузеру токен не нужен.
* Обзор Mihomo: нативный WS для /traffic и /memory + резервный потоковый GET при необходимости.
*/
export function mihomoWsUrl(alias: string, path: string): string {
const p = path.replace(/^\/+/, '');
const base =
gatewayBase() || (typeof window !== 'undefined' ? window.location.origin.replace(/\/$/, '') : '');
let wsBase = base;
if (wsBase.startsWith('https://')) wsBase = `wss://${wsBase.slice('https://'.length)}`;
else if (wsBase.startsWith('http://')) wsBase = `ws://${wsBase.slice('http://'.length)}`;
return `${wsBase}/api/${encodeURIComponent(alias)}/mihomo/${p}`;
}
async function parseMihomoBody(res: Response): Promise<unknown> {
const text = await res.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
const snip = text.length > 200 ? `${text.slice(0, 200)}…` : text;
throw new ApiError(`Ответ не JSON (HTTP ${res.status}): ${snip}`, res.status, text);
}
}
export async function fetchMihomoMeta(alias: string): Promise<MihomoMetaResponse> {
const res = await fetch(mihomoUrl(alias, 'meta'));
const body = (await parseMihomoBody(res)) as Record<string, unknown> | null;
if (res.status === 404) {
throw new ApiError('Mihomo не настроен для этой ноды', 404, body);
}
if (!res.ok) {
throw new ApiError(`Mihomo meta HTTP ${res.status}`, res.status, body);
}
if (!body || body.ok !== true) {
throw new ApiError('Mihomo meta: неверный ответ', res.status, body);
}
return body as unknown as MihomoMetaResponse;
}
export async function fetchMihomoJson<T>(alias: string, path: string): Promise<T> {
const res = await fetch(mihomoUrl(alias, path));
const body = await parseMihomoBody(res);
if (res.status === 404) {
throw new ApiError('Mihomo не настроен для этой ноды', 404, body);
}
if (!res.ok) {
throw new ApiError(`Mihomo HTTP ${res.status}`, res.status, body);
}
return body as T;
}
export async function mihomoPut(alias: string, path: string, jsonBody: unknown): Promise<void> {
const res = await fetch(mihomoUrl(alias, path), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(jsonBody)
});
if (res.status === 404) {
const body = await parseMihomoBody(res);
throw new ApiError('Mihomo не настроен для этой ноды', 404, body);
}
if (!res.ok) {
const body = await parseMihomoBody(res);
throw new ApiError(`Mihomo PUT HTTP ${res.status}`, res.status, body);
}
}
export type AggEnvelope<T> = {
ok: true;
generated_at: string;
partial?: boolean;
data: T;
};
export type IncidentSeverity = 'info' | 'warning' | 'critical';
export type IncidentStatus = 'firing';
export type IncidentAction = {
label: string;
href: string;
};
export type IncidentItem = {
id: string;
kind: string;
severity: IncidentSeverity;
status: IncidentStatus;
title: string;
summary: string;
affected_aliases?: string[];
metric_name?: string;
metric_value?: number;
metric_threshold?: number;
actions?: IncidentAction[];
};
export type IncidentsData = {
items: IncidentItem[];
total: number;
critical_total: number;
warning_total: number;
info_total: number;
};
export class ApiError extends Error {
constructor(
message: string,
readonly status?: number,
readonly body?: unknown
) {
super(message);
this.name = 'ApiError';
}
}
async function parseJson(res: Response): Promise<unknown> {
const text = await res.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
throw new ApiError('Ответ не JSON', res.status, text);
}
}
export async function fetchAggSummary(params?: {
aliases?: string;
top_n?: number;
}): Promise<AggEnvelope<SummaryData>> {
const q = new URLSearchParams();
if (params?.aliases) q.set('aliases', params.aliases);
if (params?.top_n != null) q.set('top_n', String(params.top_n));
const url = `${gatewayBase()}/api/agg/summary${q.toString() ? `?${q}` : ''}`;
const res = await fetch(url);
const body = (await parseJson(res)) as Record<string, unknown> | null;
if (!res.ok) {
throw new ApiError(`summary HTTP ${res.status}`, res.status, body);
}
if (!body || body.ok !== true) {
throw new ApiError('summary: ok !== true', res.status, body);
}
return body as AggEnvelope<SummaryData>;
}
export async function fetchAggFleetStatus(params?: { aliases?: string }): Promise<
AggEnvelope<components['schemas']['FleetStatusData']>
> {
const q = new URLSearchParams();
if (params?.aliases) q.set('aliases', params.aliases);
const url = `${gatewayBase()}/api/agg/fleet-status${q.toString() ? `?${q}` : ''}`;
const res = await fetch(url);
const body = (await parseJson(res)) as Record<string, unknown> | null;
if (!res.ok) throw new ApiError(`fleet-status HTTP ${res.status}`, res.status, body);
if (!body || body.ok !== true) throw new ApiError('fleet-status: ok !== true', res.status, body);
return body as AggEnvelope<components['schemas']['FleetStatusData']>;
}
export async function fetchAggUniqueIps(params?: {
aliases?: string;
geo?: boolean;
}): Promise<AggEnvelope<components['schemas']['UniqueIPsRow'][]>> {
const q = new URLSearchParams();
if (params?.aliases) q.set('aliases', params.aliases);
if (params?.geo === false) q.set('geo', 'false');
const url = `${gatewayBase()}/api/agg/unique-ips${q.toString() ? `?${q}` : ''}`;
const res = await fetch(url);
const body = (await parseJson(res)) as Record<string, unknown> | null;
if (!res.ok) throw new ApiError(`unique-ips HTTP ${res.status}`, res.status, body);
if (!body || body.ok !== true) throw new ApiError('unique-ips: ok !== true', res.status, body);
return body as AggEnvelope<components['schemas']['UniqueIPsRow'][]>;
}
export async function fetchAggUsers(params?: {
aliases?: string;
include_links?: boolean;
}): Promise<AggEnvelope<components['schemas']['UsersRow'][]>> {
const q = new URLSearchParams();
if (params?.aliases) q.set('aliases', params.aliases);
if (params?.include_links) q.set('include_links', 'true');
const url = `${gatewayBase()}/api/agg/users${q.toString() ? `?${q}` : ''}`;
const res = await fetch(url);
const body = (await parseJson(res)) as Record<string, unknown> | null;
if (!res.ok) throw new ApiError(`users HTTP ${res.status}`, res.status, body);
if (!body || body.ok !== true) throw new ApiError('users: ok !== true', res.status, body);
return body as AggEnvelope<components['schemas']['UsersRow'][]>;
}
export async function fetchAggUser(
username: string,
params?: { aliases?: string; include_links?: boolean }
): Promise<AggEnvelope<components['schemas']['UsersRow']>> {
const q = new URLSearchParams();
if (params?.aliases) q.set('aliases', params.aliases);
if (params?.include_links) q.set('include_links', 'true');
const path = encodeURIComponent(username);
const url = `${gatewayBase()}/api/agg/user/${path}${q.toString() ? `?${q}` : ''}`;
const res = await fetch(url);
const body = (await parseJson(res)) as Record<string, unknown> | null;
if (res.status === 404) throw new ApiError('Пользователь не найден', 404, body);
if (!res.ok) throw new ApiError(`user HTTP ${res.status}`, res.status, body);
if (!body || body.ok !== true) throw new ApiError('user: ok !== true', res.status, body);
return body as AggEnvelope<components['schemas']['UsersRow']>;
}
export async function fetchAggIncidents(params?: { aliases?: string }): Promise<AggEnvelope<IncidentsData>> {
const q = new URLSearchParams();
if (params?.aliases) q.set('aliases', params.aliases);
const url = `${gatewayBase()}/api/agg/incidents${q.toString() ? `?${q}` : ''}`;
const res = await fetch(url);
const body = (await parseJson(res)) as Record<string, unknown> | null;
if (!res.ok) throw new ApiError(`incidents HTTP ${res.status}`, res.status, body);
if (!body || body.ok !== true) throw new ApiError('incidents: ok !== true', res.status, body);
return body as AggEnvelope<IncidentsData>;
}
export function liveEventsUrl(params?: { aliases?: string }): string {
const q = new URLSearchParams();
if (params?.aliases) q.set('aliases', params.aliases);
return `${gatewayBase()}/api/live/events${q.toString() ? `?${q}` : ''}`;
}
/** Путь к upstream без префикса /v1 — шлюз сам добавляет path_prefix. */
function apiUrl(alias: string, path: string): string {
const p = path.replace(/^\/+/, '');
return `${gatewayBase()}/api/${encodeURIComponent(alias)}/${p}`;
}
/** GET к Telemt через шлюз: путь без `/v1/` (например `health`, `stats/summary`). */
export async function fetchTelemt<T>(alias: string, path: string): Promise<TelemtSuccess<T>> {
const res = await fetch(apiUrl(alias, path));
const body = await parseJson(res);
if (!res.ok) {
const err = body as TelemtErrorBody | null;
const msg = err?.error?.message ?? `HTTP ${res.status}`;
throw new ApiError(msg, res.status, body);
}
if (!body || (body as TelemtSuccess<T>).ok !== true) {
throw new ApiError('Telemt: ok !== true', res.status, body);
}
return body as TelemtSuccess<T>;
}
export async function fetchStatsSummary(alias: string): Promise<TelemtSuccess<StatsSummaryData>> {
return fetchTelemt<StatsSummaryData>(alias, 'stats/summary');
}
export async function fetchUsersList(alias: string): Promise<TelemtSuccess<UserInfo[]>> {
return fetchTelemt<UserInfo[]>(alias, 'users');
}
export async function fetchUserOne(alias: string, username: string): Promise<TelemtSuccess<UserInfo>> {
const u = encodeURIComponent(username);
return fetchTelemt<UserInfo>(alias, `users/${u}`);
}
export async function createUser(
alias: string,
body: {
username: string;
secret?: string;
user_ad_tag?: string;
max_tcp_conns?: number;
expiration_rfc3339?: string;
data_quota_bytes?: number;
max_unique_ips?: number;
}
): Promise<TelemtSuccess<{ user: UserInfo; secret: string }>> {
const res = await fetch(apiUrl(alias, 'users'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const parsed = await parseJson(res);
if (res.status !== 201) {
const err = parsed as TelemtErrorBody | null;
throw new ApiError(err?.error?.message ?? `HTTP ${res.status}`, res.status, parsed);
}
if (!parsed || (parsed as TelemtSuccess<{ user: UserInfo; secret: string }>).ok !== true) {
throw new ApiError('create: ok !== true', res.status, parsed);
}
return parsed as TelemtSuccess<{ user: UserInfo; secret: string }>;
}
export async function patchUser(
alias: string,
username: string,
body: {
secret?: string;
user_ad_tag?: string;
max_tcp_conns?: number;
expiration_rfc3339?: string | null;
data_quota_bytes?: number;
max_unique_ips?: number;
},
ifMatch?: string | null
): Promise<TelemtSuccess<UserInfo>> {
const u = encodeURIComponent(username);
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (ifMatch) headers['If-Match'] = ifMatch;
const res = await fetch(apiUrl(alias, `users/${u}`), {
method: 'PATCH',
headers,
body: JSON.stringify(body)
});
const parsed = await parseJson(res);
if (!res.ok) {
const err = parsed as TelemtErrorBody | null;
throw new ApiError(err?.error?.message ?? `HTTP ${res.status}`, res.status, parsed);
}
if (!parsed || (parsed as TelemtSuccess<UserInfo>).ok !== true) {
throw new ApiError('patch: ok !== true', res.status, parsed);
}
return parsed as TelemtSuccess<UserInfo>;
}
export async function deleteUser(
alias: string,
username: string,
ifMatch?: string | null
): Promise<TelemtSuccess<string>> {
const u = encodeURIComponent(username);
const headers: Record<string, string> = {};
if (ifMatch) headers['If-Match'] = ifMatch;
const res = await fetch(apiUrl(alias, `users/${u}`), { method: 'DELETE', headers });
const parsed = await parseJson(res);
if (!res.ok) {
const err = parsed as TelemtErrorBody | null;
throw new ApiError(err?.error?.message ?? `HTTP ${res.status}`, res.status, parsed);
}
if (!parsed || (parsed as TelemtSuccess<string>).ok !== true) {
throw new ApiError('delete: ok !== true', res.status, parsed);
}
return parsed as TelemtSuccess<string>;
}