- 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.
360 lines
13 KiB
TypeScript
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>;
|
|
}
|