Enhance API and UI for incident management and live updates
- 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:
@@ -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`), не в браузере.
|
||||
|
||||
@@ -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(/^\/+/, '');
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user