Enhance API and UI for incident management and live updates
Publish telemt-api gateway Docker image / test (push) Successful in 24s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 1m58s

- Added a new endpoint `/api/agg/incidents` to provide a normalized snapshot of incidents for fleet triage, including severity and recommended actions.
- Implemented live event streaming via `/api/live/events` for real-time updates on fleet status and incidents, enhancing observability.
- Updated the Web UI to include dedicated sections for incidents and live updates, improving user navigation and access to critical information.
- Enhanced API documentation to reflect new endpoints and their functionalities, ensuring clarity for developers and users.
This commit is contained in:
Denozordec
2026-03-30 19:17:29 +07:00
parent af11a49c81
commit 8c8ccce6ee
18 changed files with 1655 additions and 28 deletions
+19
View File
@@ -2,6 +2,14 @@
SvelteKit + shadcn-svelte. В **production** статика собирается и **встраивается в образ шлюза** ([Dockerfile](../Dockerfile) в корне репозитория): панель и API на **одном порту** (например `http://127.0.0.1:8080/` — UI, `/api/…` — шлюз).
## Основные разделы панели
- `/` — обзор флота (KPI, активные IP, сводка по нодам)
- `/users`, `/users/[username]` — пользователи и детали
- `/ips` — unique IP + GeoIP карта
- `/incidents` — triage-интерфейс инцидентов (`ack/resolved/owner/note` в localStorage)
- `/live` — live snapshot (SSE, авто-reconnect)
## Переменная `PUBLIC_TELEMT_GATEWAY_URL`
| Значение | Когда |
@@ -42,6 +50,17 @@ npm run gen:api
Источник: [../docs/AGGREGATE_OPENAPI.yaml](../docs/AGGREGATE_OPENAPI.yaml).
## URL-driven controls (операторский режим)
На ключевых страницах используются query-параметры:
- `aliases=node-a,node-b` — фильтр по нодам
- `refresh=0|10..300` — auto-refresh (0 выключает polling)
- `include_links=0|1` — для `/users`
- `geo=0|1` — для `/ips`
Live-страница `/live` работает через SSE endpoint шлюза: `/api/live/events`.
## Ограничения
- Секреты upstream к Telemt задаются на шлюзе (`authorization_env`), не в браузере.
+48
View File
@@ -20,6 +20,37 @@ export type AggEnvelope<T> = {
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,
@@ -120,6 +151,23 @@ export async function fetchAggUser(
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(/^\/+/, '');
+22
View File
@@ -9,6 +9,8 @@
import NetworkIcon from '@lucide/svelte/icons/network';
import SettingsIcon from '@lucide/svelte/icons/settings';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import SirenIcon from '@lucide/svelte/icons/siren';
import RadioIcon from '@lucide/svelte/icons/radio';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
let {
@@ -76,6 +78,26 @@
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active('/incidents')} tooltipContent="Incidents">
{#snippet child({ props })}
<a href="/incidents" {...props}>
<SirenIcon />
<span>Инциденты</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active('/live')} tooltipContent="Live">
{#snippet child({ props })}
<a href="/live" {...props}>
<RadioIcon />
<span>Live</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Group>
{#if serverAliases.length > 0}
+114 -10
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import {
ApiError,
fetchAggFleetStatus,
@@ -73,6 +75,14 @@
let uniqueIps = $state<components['schemas']['UniqueIPsRow'][] | null>(null);
let generatedAt = $state<string | null>(null);
let partial = $state(false);
let aliases = $state('');
let aliasesInput = $state('');
let refreshSeconds = $state(30);
let refreshInput = $state('30');
let lastSuccessAtMs = $state<number | null>(null);
let nowMs = $state(Date.now());
let pollTimer: ReturnType<typeof setInterval> | null = null;
let staleTimer: ReturnType<typeof setInterval> | null = null;
let nodeStats = $state<
Record<
string,
@@ -85,24 +95,25 @@
err = null;
try {
const [s, f, u] = await Promise.all([
fetchAggSummary({ top_n: 15 }),
fetchAggFleetStatus(),
fetchAggUniqueIps({ geo: true })
fetchAggSummary({ top_n: 15, aliases: aliases || undefined }),
fetchAggFleetStatus({ aliases: aliases || undefined }),
fetchAggUniqueIps({ aliases: aliases || undefined, geo: true })
]);
generatedAt = s.generated_at;
partial = !!(s.partial || f.partial || u.partial);
summary = s.data;
fleet = f.data ?? null;
uniqueIps = u.data ?? null;
lastSuccessAtMs = Date.now();
// Как бейдж «OK» в таблице: только health + system/info (без x.ok — в JSON поле ok опционально).
const aliases = (fleet?.servers ?? [])
const okAliases = (fleet?.servers ?? [])
.filter((x) => x.alias && x.health_ok && x.system_info_ok)
.map((x) => x.alias as string);
const next: typeof nodeStats = {};
const concurrency = 4;
for (let i = 0; i < aliases.length; i += concurrency) {
const batch = aliases.slice(i, i + concurrency);
for (let i = 0; i < okAliases.length; i += concurrency) {
const batch = okAliases.slice(i, i + concurrency);
await Promise.all(
batch.map(async (alias) => {
try {
@@ -130,7 +141,70 @@
}
}
onMount(load);
function parseRefresh(raw: string | null): number {
if (raw == null || raw.trim() === '') return 30;
const n = Number(raw);
if (!Number.isFinite(n)) return 30;
if (n === 0) return 0;
return Math.max(10, Math.min(300, Math.floor(n)));
}
function restartPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (refreshSeconds > 0) {
pollTimer = setInterval(() => {
void load();
}, refreshSeconds * 1000);
}
}
async function applyControls() {
const q = new URLSearchParams(page.url.searchParams);
const normalizedAliases = aliasesInput.trim();
const parsedRefresh = parseRefresh(refreshInput);
if (normalizedAliases) q.set('aliases', normalizedAliases);
else q.delete('aliases');
if (parsedRefresh === 30) q.delete('refresh');
else q.set('refresh', String(parsedRefresh));
const qs = q.toString();
await goto(`${page.url.pathname}${qs ? `?${qs}` : ''}`, {
replaceState: true,
noScroll: true,
keepFocus: true
});
}
let queryKey = $derived(page.url.searchParams.toString());
$effect(() => {
queryKey;
if (typeof window === 'undefined') return;
aliases = page.url.searchParams.get('aliases')?.trim() ?? '';
refreshSeconds = parseRefresh(page.url.searchParams.get('refresh'));
aliasesInput = aliases;
refreshInput = String(refreshSeconds);
restartPolling();
void load();
});
let staleSeconds = $derived.by(() =>
lastSuccessAtMs == null ? null : Math.max(0, Math.floor((nowMs - lastSuccessAtMs) / 1000))
);
let isStale = $derived.by(() =>
staleSeconds == null ? true : staleSeconds > (refreshSeconds > 0 ? refreshSeconds * 2 : 60)
);
onMount(() => {
staleTimer = setInterval(() => {
nowMs = Date.now();
}, 1000);
return () => {
if (pollTimer) clearInterval(pollTimer);
if (staleTimer) clearInterval(staleTimer);
};
});
/** Число из ответа Telemt (поле может отсутствовать или прийти строкой). */
function numStat(v: unknown, fallback = 0): number {
@@ -284,9 +358,39 @@
</p>
{/if}
</div>
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
<div class="flex items-center gap-2">
<Badge variant={isStale ? 'secondary' : 'default'}>
{isStale ? 'stale' : 'live'}{#if staleSeconds != null} · {staleSeconds}s{/if}
</Badge>
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
</Button>
</div>
</div>
<div class="mb-4 flex flex-wrap items-end gap-2">
<label class="text-xs text-muted-foreground">
Aliases
<input
type="text"
class="mt-1 h-9 w-56 rounded-md border bg-background px-2 text-sm"
bind:value={aliasesInput}
placeholder="node-a,node-b"
/>
</label>
<label class="text-xs text-muted-foreground">
Refresh (sec)
<input
type="number"
min="0"
max="300"
class="mt-1 h-9 w-28 rounded-md border bg-background px-2 text-sm"
bind:value={refreshInput}
/>
</label>
<Button variant="outline" size="sm" onclick={() => void applyControls()} disabled={loading}>
Apply
</Button>
</div>
+351
View File
@@ -0,0 +1,351 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import {
ApiError,
fetchAggIncidents,
type IncidentItem,
type IncidentsData
} from '$lib/api/client.js';
import * as Card from '$lib/components/ui/card/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
type TriageState = {
ack?: boolean;
resolved?: boolean;
owner?: string;
note?: string;
};
const TRIAGE_KEY = 'telemt.incidents.triage.v1';
let loading = $state(true);
let err = $state<string | null>(null);
let partial = $state(false);
let generatedAt = $state<string | null>(null);
let data = $state<IncidentsData | null>(null);
let aliases = $state('');
let aliasesInput = $state('');
let refreshSeconds = $state(30);
let refreshInput = $state('30');
let lastSuccessAtMs = $state<number | null>(null);
let nowMs = $state(Date.now());
let pollTimer: ReturnType<typeof setInterval> | null = null;
let staleTimer: ReturnType<typeof setInterval> | null = null;
let triageById = $state<Record<string, TriageState>>({});
function parseRefresh(raw: string | null): number {
if (raw == null || raw.trim() === '') return 30;
const n = Number(raw);
if (!Number.isFinite(n)) return 30;
if (n === 0) return 0;
return Math.max(10, Math.min(300, Math.floor(n)));
}
function triageFor(id: string): TriageState {
return triageById[id] ?? {};
}
function saveTriage() {
if (typeof localStorage === 'undefined') return;
localStorage.setItem(TRIAGE_KEY, JSON.stringify(triageById));
}
function loadTriage() {
if (typeof localStorage === 'undefined') return;
try {
const raw = localStorage.getItem(TRIAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as Record<string, TriageState>;
if (parsed && typeof parsed === 'object') triageById = parsed;
} catch {
triageById = {};
}
}
function patchTriage(id: string, patch: Partial<TriageState>) {
triageById = {
...triageById,
[id]: {
...triageFor(id),
...patch
}
};
saveTriage();
}
function toggleAck(id: string) {
const t = triageFor(id);
patchTriage(id, { ack: !t.ack });
}
function toggleResolved(id: string) {
const t = triageFor(id);
patchTriage(id, { resolved: !t.resolved });
}
function severityVariant(sev: IncidentItem['severity']): 'default' | 'secondary' | 'destructive' | 'outline' {
if (sev === 'critical') return 'destructive';
if (sev === 'warning') return 'secondary';
return 'outline';
}
function restartPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (refreshSeconds > 0) {
pollTimer = setInterval(() => {
void load();
}, refreshSeconds * 1000);
}
}
async function load() {
loading = true;
err = null;
try {
const env = await fetchAggIncidents({ aliases: aliases || undefined });
data = env.data ?? null;
partial = !!env.partial;
generatedAt = env.generated_at;
lastSuccessAtMs = Date.now();
} catch (e) {
err = e instanceof ApiError ? e.message : String(e);
} finally {
loading = false;
}
}
async function applyControls() {
const q = new URLSearchParams(page.url.searchParams);
const normalizedAliases = aliasesInput.trim();
const parsedRefresh = parseRefresh(refreshInput);
if (normalizedAliases) q.set('aliases', normalizedAliases);
else q.delete('aliases');
if (parsedRefresh === 30) q.delete('refresh');
else q.set('refresh', String(parsedRefresh));
const qs = q.toString();
await goto(`${page.url.pathname}${qs ? `?${qs}` : ''}`, {
replaceState: true,
noScroll: true,
keepFocus: true
});
}
let queryKey = $derived(page.url.searchParams.toString());
$effect(() => {
queryKey;
if (typeof window === 'undefined') return;
aliases = page.url.searchParams.get('aliases')?.trim() ?? '';
refreshSeconds = parseRefresh(page.url.searchParams.get('refresh'));
aliasesInput = aliases;
refreshInput = String(refreshSeconds);
restartPolling();
void load();
});
let staleSeconds = $derived.by(() =>
lastSuccessAtMs == null ? null : Math.max(0, Math.floor((nowMs - lastSuccessAtMs) / 1000))
);
let isStale = $derived.by(() =>
staleSeconds == null ? true : staleSeconds > (refreshSeconds > 0 ? refreshSeconds * 2 : 60)
);
onMount(() => {
loadTriage();
staleTimer = setInterval(() => {
nowMs = Date.now();
}, 1000);
return () => {
if (pollTimer) clearInterval(pollTimer);
if (staleTimer) clearInterval(staleTimer);
};
});
</script>
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Инциденты</h1>
<p class="text-sm text-muted-foreground">Сводка и triage по всему флоту.</p>
{#if generatedAt}
<p class="mt-1 text-xs text-muted-foreground">Снимок: {new Date(generatedAt).toLocaleString()}</p>
{/if}
</div>
<div class="flex items-center gap-2">
<Badge variant={isStale ? 'secondary' : 'default'}>
{isStale ? 'stale' : 'live'}{#if staleSeconds != null} · {staleSeconds}s{/if}
</Badge>
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
</Button>
</div>
</div>
<div class="mb-4 flex flex-wrap items-end gap-2">
<label class="text-xs text-muted-foreground">
Aliases
<input
type="text"
class="mt-1 h-9 w-56 rounded-md border bg-background px-2 text-sm"
bind:value={aliasesInput}
placeholder="node-a,node-b"
/>
</label>
<label class="text-xs text-muted-foreground">
Refresh (sec)
<input
type="number"
min="0"
max="300"
class="mt-1 h-9 w-28 rounded-md border bg-background px-2 text-sm"
bind:value={refreshInput}
/>
</label>
<Button variant="outline" size="sm" onclick={() => void applyControls()} disabled={loading}>
Apply
</Button>
</div>
{#if partial}
<Alert class="mb-4">
<AlertTitle>Частичные данные</AlertTitle>
<AlertDescription>Не все upstream ответили успешно.</AlertDescription>
</Alert>
{/if}
{#if err}
<Alert variant="destructive">
<AlertTitle>Ошибка</AlertTitle>
<AlertDescription>{err}</AlertDescription>
</Alert>
{:else}
<div class="mb-4 grid gap-4 sm:grid-cols-3">
<Card.Root>
<Card.Header class="pb-2">
<Card.Description>Critical</Card.Description>
<Card.Title class="text-2xl text-red-500 tabular-nums">{data?.critical_total ?? 0}</Card.Title>
</Card.Header>
</Card.Root>
<Card.Root>
<Card.Header class="pb-2">
<Card.Description>Warning</Card.Description>
<Card.Title class="text-2xl text-amber-500 tabular-nums">{data?.warning_total ?? 0}</Card.Title>
</Card.Header>
</Card.Root>
<Card.Root>
<Card.Header class="pb-2">
<Card.Description>Info</Card.Description>
<Card.Title class="text-2xl tabular-nums">{data?.info_total ?? 0}</Card.Title>
</Card.Header>
</Card.Root>
</div>
<Card.Root>
<Card.Content class="p-0">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Severity</Table.Head>
<Table.Head>Инцидент</Table.Head>
<Table.Head>Серверы</Table.Head>
<Table.Head>Runbook</Table.Head>
<Table.Head>Triage</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if (data?.items.length ?? 0) === 0}
<Table.Row>
<Table.Cell colspan={5} class="p-4 text-center text-muted-foreground">
Инцидентов нет
</Table.Cell>
</Table.Row>
{:else}
{#each data?.items ?? [] as item (item.id)}
<Table.Row>
<Table.Cell>
<Badge variant={severityVariant(item.severity)}>{item.severity}</Badge>
</Table.Cell>
<Table.Cell class="max-w-[360px]">
<div class="font-medium">{item.title}</div>
<div class="text-xs text-muted-foreground">{item.summary}</div>
{#if item.metric_name}
<div class="mt-1 text-xs text-muted-foreground">
{item.metric_name}: {item.metric_value} / {item.metric_threshold}
</div>
{/if}
</Table.Cell>
<Table.Cell>
<div class="flex flex-wrap gap-1">
{#each item.affected_aliases ?? [] as alias (alias)}
<Badge variant="outline">{alias}</Badge>
{/each}
</div>
</Table.Cell>
<Table.Cell class="max-w-[240px]">
<div class="flex flex-wrap gap-1">
{#each item.actions ?? [] as action (action.label + action.href)}
<a href={action.href} class="text-primary hover:underline text-xs">
{action.label}
</a>
{/each}
</div>
</Table.Cell>
<Table.Cell class="min-w-[280px]">
{@const triage = triageFor(item.id)}
<div class="mb-2 flex flex-wrap gap-1">
<Button
size="xs"
variant={triage.ack ? 'default' : 'outline'}
onclick={() => toggleAck(item.id)}
>
{triage.ack ? 'unack' : 'ack'}
</Button>
<Button
size="xs"
variant={triage.resolved ? 'default' : 'outline'}
onclick={() => toggleResolved(item.id)}
>
{triage.resolved ? 'unresolve' : 'resolve'}
</Button>
</div>
<div class="mb-1">
<input
type="text"
class="h-8 w-full rounded-md border bg-background px-2 text-xs"
placeholder="owner"
value={triage.owner ?? ''}
oninput={(e) =>
patchTriage(item.id, {
owner: (e.currentTarget as HTMLInputElement).value
})}
/>
</div>
<div>
<input
type="text"
class="h-8 w-full rounded-md border bg-background px-2 text-xs"
placeholder="note"
value={triage.note ?? ''}
oninput={(e) =>
patchTriage(item.id, {
note: (e.currentTarget as HTMLInputElement).value
})}
/>
</div>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</Card.Content>
</Card.Root>
{/if}
+116 -6
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { fetchAggUniqueIps, ApiError } from '$lib/api/client.js';
import type { components } from '$lib/api/aggregate.gen.js';
import type * as Leaflet from 'leaflet';
@@ -17,6 +19,14 @@
let rows = $state<components['schemas']['UniqueIPsRow'][]>([]);
let partial = $state(false);
let geo = $state(true);
let aliases = $state('');
let aliasesInput = $state('');
let refreshSeconds = $state(30);
let refreshInput = $state('30');
let lastSuccessAtMs = $state<number | null>(null);
let nowMs = $state(Date.now());
let pollTimer: ReturnType<typeof setInterval> | null = null;
let staleTimer: ReturnType<typeof setInterval> | null = null;
let mapEl = $state<HTMLDivElement | null>(null);
let leaflet: typeof import('leaflet') | null = null;
@@ -273,9 +283,10 @@
// Ждём, пока bind:this завершится и контейнеру карты будет доступен размер.
await tick();
initMap();
const env = await fetchAggUniqueIps({ geo });
const env = await fetchAggUniqueIps({ aliases: aliases || undefined, geo });
partial = !!env.partial;
rows = env.data ?? [];
lastSuccessAtMs = Date.now();
} catch (e) {
err = e instanceof ApiError ? e.message : String(e);
} finally {
@@ -288,11 +299,77 @@
}
}
onMount(() => {
// Инициализация Leaflet и загрузка данных происходит в `load()`.
// Это избегает проблем с SSR (если он включён).
function parseRefresh(raw: string | null): number {
if (raw == null || raw.trim() === '') return 30;
const n = Number(raw);
if (!Number.isFinite(n)) return 30;
if (n === 0) return 0;
return Math.max(10, Math.min(300, Math.floor(n)));
}
function parse01(raw: string | null, fallback: boolean): boolean {
if (raw == null || raw === '') return fallback;
return raw === '1';
}
function restartPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (refreshSeconds > 0) {
pollTimer = setInterval(() => {
void load();
}, refreshSeconds * 1000);
}
}
async function applyControls() {
const q = new URLSearchParams(page.url.searchParams);
const normalizedAliases = aliasesInput.trim();
const parsedRefresh = parseRefresh(refreshInput);
if (normalizedAliases) q.set('aliases', normalizedAliases);
else q.delete('aliases');
if (parsedRefresh === 30) q.delete('refresh');
else q.set('refresh', String(parsedRefresh));
q.set('geo', geo ? '1' : '0');
const qs = q.toString();
await goto(`${page.url.pathname}${qs ? `?${qs}` : ''}`, {
replaceState: true,
noScroll: true,
keepFocus: true
});
}
let queryKey = $derived(page.url.searchParams.toString());
$effect(() => {
queryKey;
if (typeof window === 'undefined') return;
aliases = page.url.searchParams.get('aliases')?.trim() ?? '';
refreshSeconds = parseRefresh(page.url.searchParams.get('refresh'));
geo = parse01(page.url.searchParams.get('geo'), true);
aliasesInput = aliases;
refreshInput = String(refreshSeconds);
restartPolling();
void load();
});
let staleSeconds = $derived.by(() =>
lastSuccessAtMs == null ? null : Math.max(0, Math.floor((nowMs - lastSuccessAtMs) / 1000))
);
let isStale = $derived.by(() =>
staleSeconds == null ? true : staleSeconds > (refreshSeconds > 0 ? refreshSeconds * 2 : 60)
);
onMount(() => {
staleTimer = setInterval(() => {
nowMs = Date.now();
}, 1000);
return () => {
if (pollTimer) clearInterval(pollTimer);
if (staleTimer) clearInterval(staleTimer);
};
});
</script>
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
@@ -301,17 +378,50 @@
<p class="text-sm text-muted-foreground">Снимок active/recent с шлюза; GeoIP из конфига шлюза.</p>
</div>
<div class="flex items-center gap-2">
<Badge variant={isStale ? 'secondary' : 'default'}>
{isStale ? 'stale' : 'live'}{#if staleSeconds != null} · {staleSeconds}s{/if}
</Badge>
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" bind:checked={geo} onchange={load} class="rounded border" />
<input
type="checkbox"
bind:checked={geo}
onchange={() => void applyControls()}
class="rounded border"
/>
GeoIP
</label>
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
</Button>
</div>
</div>
<div class="mb-4 flex flex-wrap items-end gap-2">
<label class="text-xs text-muted-foreground">
Aliases
<input
type="text"
class="mt-1 h-9 w-56 rounded-md border bg-background px-2 text-sm"
bind:value={aliasesInput}
placeholder="node-a,node-b"
/>
</label>
<label class="text-xs text-muted-foreground">
Refresh (sec)
<input
type="number"
min="0"
max="300"
class="mt-1 h-9 w-28 rounded-md border bg-background px-2 text-sm"
bind:value={refreshInput}
/>
</label>
<Button variant="outline" size="sm" onclick={() => void applyControls()} disabled={loading}>
Apply
</Button>
</div>
{#if partial}
<Alert class="mb-4">
<AlertTitle>Частичные данные</AlertTitle>
+348
View File
@@ -0,0 +1,348 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import {
liveEventsUrl,
type IncidentItem,
type IncidentSeverity,
type IncidentStatus
} from '$lib/api/client.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
type LiveStatus = 'healthy' | 'degraded' | 'critical';
type LiveSnapshot = {
type?: string;
timestamp?: string;
status?: LiveStatus;
partial?: boolean;
incidents?: IncidentItem[];
counts?: {
total?: number;
critical?: number;
warning?: number;
info?: number;
};
aliases_used?: string[];
};
let aliases = $state('');
let aliasesInput = $state('');
let err = $state<string | null>(null);
let connected = $state(false);
let reconnectInSec = $state<number | null>(null);
let reconnectAttempt = $state(0);
let snapshot = $state<LiveSnapshot | null>(null);
let lastEventAtMs = $state<number | null>(null);
let nowMs = $state(Date.now());
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let staleTimer: ReturnType<typeof setInterval> | null = null;
let es: EventSource | null = null;
function severityVariant(sev: IncidentSeverity): 'default' | 'secondary' | 'destructive' | 'outline' {
if (sev === 'critical') return 'destructive';
if (sev === 'warning') return 'secondary';
return 'outline';
}
function statusVariant(st: LiveStatus | undefined): 'default' | 'secondary' | 'destructive' {
if (st === 'critical') return 'destructive';
if (st === 'degraded') return 'secondary';
return 'default';
}
function normIncident(v: unknown): IncidentItem | null {
if (!v || typeof v !== 'object') return null;
const x = v as Record<string, unknown>;
const id = typeof x.id === 'string' ? x.id : '';
if (!id) return null;
const severity = (x.severity ?? 'info') as IncidentSeverity;
const status = (x.status ?? 'firing') as IncidentStatus;
return {
id,
kind: typeof x.kind === 'string' ? x.kind : '',
severity,
status,
title: typeof x.title === 'string' ? x.title : id,
summary: typeof x.summary === 'string' ? x.summary : '',
affected_aliases: Array.isArray(x.affected_aliases)
? x.affected_aliases.filter((a): a is string => typeof a === 'string')
: [],
metric_name: typeof x.metric_name === 'string' ? x.metric_name : undefined,
metric_value: typeof x.metric_value === 'number' ? x.metric_value : undefined,
metric_threshold: typeof x.metric_threshold === 'number' ? x.metric_threshold : undefined,
actions: Array.isArray(x.actions)
? x.actions
.filter((a) => a && typeof a === 'object')
.map((a) => a as Record<string, unknown>)
.filter((a) => typeof a.label === 'string' && typeof a.href === 'string')
.map((a) => ({ label: String(a.label), href: String(a.href) }))
: []
};
}
function parseSnapshot(raw: unknown): LiveSnapshot | null {
if (!raw || typeof raw !== 'object') return null;
const x = raw as Record<string, unknown>;
const incidents = Array.isArray(x.incidents) ? x.incidents.map(normIncident).filter(Boolean) : [];
const countsRaw = x.counts && typeof x.counts === 'object' ? (x.counts as Record<string, unknown>) : {};
return {
type: typeof x.type === 'string' ? x.type : undefined,
timestamp: typeof x.timestamp === 'string' ? x.timestamp : undefined,
status:
x.status === 'healthy' || x.status === 'degraded' || x.status === 'critical'
? x.status
: 'healthy',
partial: !!x.partial,
incidents: incidents as IncidentItem[],
counts: {
total: typeof countsRaw.total === 'number' ? countsRaw.total : incidents.length,
critical: typeof countsRaw.critical === 'number' ? countsRaw.critical : undefined,
warning: typeof countsRaw.warning === 'number' ? countsRaw.warning : undefined,
info: typeof countsRaw.info === 'number' ? countsRaw.info : undefined
},
aliases_used: Array.isArray(x.aliases_used)
? x.aliases_used.filter((a): a is string => typeof a === 'string')
: []
};
}
function clearReconnectTimer() {
if (!reconnectTimer) return;
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
function closeStream() {
if (es) {
es.close();
es = null;
}
connected = false;
}
function scheduleReconnect() {
clearReconnectTimer();
const delayMs = Math.min(10_000, 1000 * 2 ** Math.min(8, reconnectAttempt));
reconnectAttempt += 1;
reconnectInSec = Math.ceil(delayMs / 1000);
const target = Date.now() + delayMs;
reconnectTimer = setTimeout(() => {
reconnectInSec = null;
connectStream();
}, delayMs);
const countdown = setInterval(() => {
const left = Math.max(0, Math.ceil((target - Date.now()) / 1000));
reconnectInSec = left;
if (left <= 0) clearInterval(countdown);
}, 250);
}
function connectStream() {
closeStream();
clearReconnectTimer();
err = null;
const url = liveEventsUrl({ aliases: aliases || undefined });
const next = new EventSource(url);
next.onopen = () => {
connected = true;
reconnectAttempt = 0;
reconnectInSec = null;
};
next.onmessage = (ev) => {
try {
const parsed = parseSnapshot(JSON.parse(ev.data));
if (!parsed) return;
snapshot = parsed;
lastEventAtMs = Date.now();
err = null;
connected = true;
} catch (e) {
err = `Невалидный snapshot: ${String(e)}`;
}
};
next.addEventListener('snapshot', (ev) => {
try {
const msg = ev as MessageEvent<string>;
const parsed = parseSnapshot(JSON.parse(msg.data));
if (!parsed) return;
snapshot = parsed;
lastEventAtMs = Date.now();
err = null;
connected = true;
} catch (e) {
err = `Невалидный snapshot: ${String(e)}`;
}
});
next.onerror = () => {
connected = false;
err = 'Поток SSE разорван, переподключение...';
next.close();
if (es === next) es = null;
scheduleReconnect();
};
es = next;
}
async function applyControls() {
const q = new URLSearchParams(page.url.searchParams);
const normalizedAliases = aliasesInput.trim();
if (normalizedAliases) q.set('aliases', normalizedAliases);
else q.delete('aliases');
const qs = q.toString();
await goto(`${page.url.pathname}${qs ? `?${qs}` : ''}`, {
replaceState: true,
noScroll: true,
keepFocus: true
});
}
let queryKey = $derived(page.url.searchParams.toString());
$effect(() => {
queryKey;
if (typeof window === 'undefined') return;
aliases = page.url.searchParams.get('aliases')?.trim() ?? '';
aliasesInput = aliases;
connectStream();
});
let staleSeconds = $derived.by(() =>
lastEventAtMs == null ? null : Math.max(0, Math.floor((nowMs - lastEventAtMs) / 1000))
);
let isStale = $derived.by(() => staleSeconds == null || staleSeconds > 12);
onMount(() => {
staleTimer = setInterval(() => {
nowMs = Date.now();
}, 1000);
return () => {
closeStream();
clearReconnectTimer();
if (staleTimer) clearInterval(staleTimer);
};
});
</script>
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Live</h1>
<p class="text-sm text-muted-foreground">SSE поток инцидентов и статуса флота.</p>
</div>
<div class="flex items-center gap-2">
<Badge variant={connected ? 'default' : 'secondary'}>
{connected ? 'connected' : 'disconnected'}
</Badge>
<Badge variant={isStale ? 'secondary' : 'default'}>
{isStale ? 'stale' : 'live'}{#if staleSeconds != null} · {staleSeconds}s{/if}
</Badge>
<Button variant="outline" size="sm" onclick={() => connectStream()}>
<RefreshCwIcon class="mr-1 size-4" />
Reconnect
</Button>
</div>
</div>
<div class="mb-4 flex flex-wrap items-end gap-2">
<label class="text-xs text-muted-foreground">
Aliases
<input
type="text"
class="mt-1 h-9 w-56 rounded-md border bg-background px-2 text-sm"
bind:value={aliasesInput}
placeholder="node-a,node-b"
/>
</label>
<Button variant="outline" size="sm" onclick={() => void applyControls()}>
Apply
</Button>
</div>
{#if err}
<Alert class="mb-4">
<AlertTitle>Live stream</AlertTitle>
<AlertDescription>
{err}
{#if reconnectInSec != null}
Переподключение через {reconnectInSec}s.
{/if}
</AlertDescription>
</Alert>
{/if}
<div class="mb-4 grid gap-4 sm:grid-cols-3">
<Card.Root>
<Card.Header class="pb-2">
<Card.Description>Общий статус</Card.Description>
<Card.Title>
<Badge variant={statusVariant(snapshot?.status)}>
{snapshot?.status ?? 'healthy'}
</Badge>
</Card.Title>
</Card.Header>
</Card.Root>
<Card.Root>
<Card.Header class="pb-2">
<Card.Description>Partial</Card.Description>
<Card.Title class="text-2xl tabular-nums">{snapshot?.partial ? 'yes' : 'no'}</Card.Title>
</Card.Header>
</Card.Root>
<Card.Root>
<Card.Header class="pb-2">
<Card.Description>Timestamp</Card.Description>
<Card.Title class="text-sm">
{snapshot?.timestamp ? new Date(snapshot.timestamp).toLocaleString() : '—'}
</Card.Title>
</Card.Header>
</Card.Root>
</div>
<Card.Root>
<Card.Header>
<Card.Title>Последние incidents из snapshot</Card.Title>
<Card.Description>Всего: {snapshot?.counts?.total ?? snapshot?.incidents?.length ?? 0}</Card.Description>
</Card.Header>
<Card.Content class="p-0">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Severity</Table.Head>
<Table.Head>Title</Table.Head>
<Table.Head>Summary</Table.Head>
<Table.Head>Aliases</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if (snapshot?.incidents?.length ?? 0) === 0}
<Table.Row>
<Table.Cell colspan={4} class="p-4 text-center text-muted-foreground">
Инцидентов нет
</Table.Cell>
</Table.Row>
{:else}
{#each snapshot?.incidents ?? [] as item (item.id)}
<Table.Row>
<Table.Cell>
<Badge variant={severityVariant(item.severity)}>{item.severity}</Badge>
</Table.Cell>
<Table.Cell class="font-medium">{item.title}</Table.Cell>
<Table.Cell class="max-w-[380px] text-sm text-muted-foreground">{item.summary}</Table.Cell>
<Table.Cell>
<div class="flex flex-wrap gap-1">
{#each item.affected_aliases ?? [] as alias (alias)}
<Badge variant="outline">{alias}</Badge>
{/each}
</div>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</Card.Content>
</Card.Root>
+121 -4
View File
@@ -1,9 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { fetchAggUsers, ApiError } from '$lib/api/client.js';
import type { components } from '$lib/api/aggregate.gen.js';
import { formatMiB } from '$lib/format.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import * as Card from '$lib/components/ui/card/index.js';
@@ -16,14 +18,39 @@
let rows = $state<components['schemas']['UsersRow'][]>([]);
let partial = $state(false);
let includeLinks = $state(false);
let aliases = $state('');
let aliasesInput = $state('');
let refreshSeconds = $state(30);
let refreshInput = $state('30');
let lastSuccessAtMs = $state<number | null>(null);
let nowMs = $state(Date.now());
let pollTimer: ReturnType<typeof setInterval> | null = null;
let staleTimer: ReturnType<typeof setInterval> | null = null;
function parseRefresh(raw: string | null): number {
if (raw == null || raw.trim() === '') return 30;
const n = Number(raw);
if (!Number.isFinite(n)) return 30;
if (n === 0) return 0;
return Math.max(10, Math.min(300, Math.floor(n)));
}
function parse01(raw: string | null, fallback: boolean): boolean {
if (raw == null || raw === '') return fallback;
return raw === '1';
}
async function load() {
loading = true;
err = null;
try {
const usersEnv = await fetchAggUsers({ include_links: includeLinks });
const usersEnv = await fetchAggUsers({
aliases: aliases || undefined,
include_links: includeLinks
});
partial = !!usersEnv.partial;
rows = usersEnv.data ?? [];
lastSuccessAtMs = Date.now();
} catch (e) {
err = e instanceof ApiError ? e.message : String(e);
} finally {
@@ -31,7 +58,64 @@
}
}
onMount(load);
function restartPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (refreshSeconds > 0) {
pollTimer = setInterval(() => {
void load();
}, refreshSeconds * 1000);
}
}
async function applyControls() {
const q = new URLSearchParams(page.url.searchParams);
const normalizedAliases = aliasesInput.trim();
const parsedRefresh = parseRefresh(refreshInput);
if (normalizedAliases) q.set('aliases', normalizedAliases);
else q.delete('aliases');
if (parsedRefresh === 30) q.delete('refresh');
else q.set('refresh', String(parsedRefresh));
q.set('include_links', includeLinks ? '1' : '0');
const qs = q.toString();
await goto(`${page.url.pathname}${qs ? `?${qs}` : ''}`, {
replaceState: true,
noScroll: true,
keepFocus: true
});
}
let queryKey = $derived(page.url.searchParams.toString());
$effect(() => {
queryKey;
if (typeof window === 'undefined') return;
aliases = page.url.searchParams.get('aliases')?.trim() ?? '';
refreshSeconds = parseRefresh(page.url.searchParams.get('refresh'));
includeLinks = parse01(page.url.searchParams.get('include_links'), false);
aliasesInput = aliases;
refreshInput = String(refreshSeconds);
restartPolling();
void load();
});
let staleSeconds = $derived.by(() =>
lastSuccessAtMs == null ? null : Math.max(0, Math.floor((nowMs - lastSuccessAtMs) / 1000))
);
let isStale = $derived.by(() =>
staleSeconds == null ? true : staleSeconds > (refreshSeconds > 0 ? refreshSeconds * 2 : 60)
);
onMount(() => {
staleTimer = setInterval(() => {
nowMs = Date.now();
}, 1000);
return () => {
if (pollTimer) clearInterval(pollTimer);
if (staleTimer) clearInterval(staleTimer);
};
});
function copy(text: string) {
const value = String(text ?? '');
@@ -76,17 +160,50 @@
</p>
</div>
<div class="flex items-center gap-2">
<Badge variant={isStale ? 'secondary' : 'default'}>
{isStale ? 'stale' : 'live'}{#if staleSeconds != null} · {staleSeconds}s{/if}
</Badge>
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" bind:checked={includeLinks} onchange={load} class="rounded border" />
<input
type="checkbox"
bind:checked={includeLinks}
onchange={() => void applyControls()}
class="rounded border"
/>
Ссылки tg://proxy
</label>
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
<Button variant="outline" size="sm" onclick={() => void load()} disabled={loading}>
<RefreshCwIcon class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
</Button>
</div>
</div>
<div class="mb-4 flex flex-wrap items-end gap-2">
<label class="text-xs text-muted-foreground">
Aliases
<input
type="text"
class="mt-1 h-9 w-56 rounded-md border bg-background px-2 text-sm"
bind:value={aliasesInput}
placeholder="node-a,node-b"
/>
</label>
<label class="text-xs text-muted-foreground">
Refresh (sec)
<input
type="number"
min="0"
max="300"
class="mt-1 h-9 w-28 rounded-md border bg-background px-2 text-sm"
bind:value={refreshInput}
/>
</label>
<Button variant="outline" size="sm" onclick={() => void applyControls()} disabled={loading}>
Apply
</Button>
</div>
{#if partial}
<Alert class="mb-4">
<AlertTitle>Частичные данные</AlertTitle>