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 { 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 { const res = await fetch(mihomoUrl(alias, 'meta')); const body = (await parseMihomoBody(res)) as Record | 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(alias: string, path: string): Promise { 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 { 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 = { 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 { 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> { 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 | 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; } export async function fetchAggFleetStatus(params?: { aliases?: string }): Promise< AggEnvelope > { 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 | 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; } export async function fetchAggUniqueIps(params?: { aliases?: string; geo?: boolean; }): Promise> { 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 | 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; } export async function fetchAggUsers(params?: { aliases?: string; include_links?: boolean; }): Promise> { 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 | 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; } export async function fetchAggUser( username: string, params?: { aliases?: string; include_links?: boolean } ): Promise> { 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 | 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; } export async function fetchAggIncidents(params?: { aliases?: string }): Promise> { 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 | 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; } 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(alias: string, path: string): Promise> { 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).ok !== true) { throw new ApiError('Telemt: ok !== true', res.status, body); } return body as TelemtSuccess; } export async function fetchStatsSummary(alias: string): Promise> { return fetchTelemt(alias, 'stats/summary'); } export async function fetchUsersList(alias: string): Promise> { return fetchTelemt(alias, 'users'); } export async function fetchUserOne(alias: string, username: string): Promise> { const u = encodeURIComponent(username); return fetchTelemt(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> { 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> { const u = encodeURIComponent(username); const headers: Record = { '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).ok !== true) { throw new ApiError('patch: ok !== true', res.status, parsed); } return parsed as TelemtSuccess; } export async function deleteUser( alias: string, username: string, ifMatch?: string | null ): Promise> { const u = encodeURIComponent(username); const headers: Record = {}; 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).ok !== true) { throw new ApiError('delete: ok !== true', res.status, parsed); } return parsed as TelemtSuccess; }