Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 4m35s
Docker images / frontend-image (push) Successful in 3m29s
Docker images / updater-image (push) Successful in 41s
Docker images / netflow-agent-image (push) Successful in 34s
Docker images / backend-image (push) Successful in 1m44s
Docker images / publish-release (push) Successful in 10s
Docker images / notify-webhook (push) Successful in 2s
150 lines
23 KiB
TypeScript
150 lines
23 KiB
TypeScript
"use client"
|
||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||
import { useSearchParams } from "next/navigation"
|
||
import Link from "next/link"
|
||
import { z } from "zod"
|
||
import { logFiltersSchema, logLevels, type LogEntry } from "@mmapp/contracts"
|
||
import { Bar, BarChart, XAxis, YAxis } from "recharts"
|
||
import { DownloadIcon, PauseIcon, PlayIcon, RefreshCwIcon, SearchIcon, BookmarkPlusIcon, ActivityIcon } from "lucide-react"
|
||
import { toast } from "sonner"
|
||
import { PageHeader } from "@/components/page-header"
|
||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame"
|
||
import { Badge } from "@/components/reui/badge"
|
||
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
||
import { Filters } from "@/components/reui/filters/filters"
|
||
import { createFilterQuery, createFilterRule, flattenFilterConditions } from "@/components/reui/filters/filters-query"
|
||
import type { FilterField, FilterQuery } from "@/components/reui/filters/filters-types"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||
import { Skeleton } from "@/components/ui/skeleton"
|
||
import { useDataSource } from "@/lib/data-source"
|
||
import { can, ensureAuthConfig, getClaims } from "@/lib/auth"
|
||
import { firewallServersSchema } from "@/lib/firewall-response"
|
||
import { FILTER_LABELS_RU } from "@/lib/data-filters/filter-labels-ru"
|
||
import { requestJson } from "@/shared/api/http-client"
|
||
import { exportLogs } from "@/shared/api/logs"
|
||
import { useRouterLogs } from "@/hooks/use-router-logs"
|
||
import { LogsGrid } from "./logs-grid"
|
||
import { LogDetailSheet } from "./log-detail-sheet"
|
||
import { LEVEL_COLORS, LEVEL_LABELS, formatLogTime } from "./log-presentation"
|
||
|
||
const RANGES = [{ value: "15m", label: "15 минут" }, { value: "1h", label: "1 час" }, { value: "24h", label: "24 часа" }, { value: "7d", label: "7 дней" }, { value: "14d", label: "14 дней" }, { value: "custom", label: "Свой период" }]
|
||
const RANGE_MS: Record<string, number> = { "15m": 900000, "1h": 3600000, "24h": 86400000, "7d": 604800000, "14d": 1209600000 }
|
||
const PRESETS = [{ value: "", label: "Все события" }, { value: "errors", label: "Ошибки и critical" }, { value: "warnings", label: "Предупреждения" }, { value: "login-failure", label: "Ошибки входа · шаблон" }, { value: "interfaces", label: "Интерфейсы" }, { value: "routing", label: "Маршрутизация" }, { value: "firewall", label: "Firewall" }]
|
||
const viewSchema = z.array(z.object({ name: z.string().max(60), query: z.string().max(4000) })).max(20)
|
||
function navigate(values: Record<string, string | null>, replace = false) {
|
||
const params = new URLSearchParams(window.location.search)
|
||
for (const [key, value] of Object.entries(values)) { if (value) params.set(key, value); else params.delete(key) }
|
||
window.history[replace ? "replaceState" : "pushState"](null, "", "/logs?" + params)
|
||
}
|
||
export function LogsWorkspace() { return <Suspense fallback={<Skeleton className="h-64 w-full" />}><LogsRoute /></Suspense> }
|
||
function LogsRoute() {
|
||
const { backendUrl, mode, prefsHydrated } = useDataSource()
|
||
return prefsHydrated ? <LogsCatalog key={JSON.stringify([backendUrl, mode])} base={backendUrl} active={mode === "live"} /> : <p role="status" className="p-4">Загружаем источник…</p>
|
||
}
|
||
function LogsCatalog({ base, active }: { base: string; active: boolean }) {
|
||
const params = useSearchParams(), selectedId = params.get("server") ?? ""
|
||
const [servers, setServers] = useState<z.infer<typeof firewallServersSchema>>([]), [error, setError] = useState<string | null>(null)
|
||
const [ready, setReady] = useState(false), [loading, setLoading] = useState(active), [retry, setRetry] = useState(0)
|
||
useEffect(() => {
|
||
const ac = new AbortController()
|
||
void ensureAuthConfig().then(async () => {
|
||
if (!active || ac.signal.aborted) return
|
||
if (!can("mm:servers:read")) throw new Error("Для чтения логов требуется mm:servers:read")
|
||
const rows = firewallServersSchema.parse(await requestJson(base, "/api/servers", { signal: ac.signal }))
|
||
if (ac.signal.aborted) return
|
||
setServers(rows); setReady(true); setError(null)
|
||
if (!new URLSearchParams(window.location.search).has("server")) navigate({ server: String(rows.find(s => s.enabled)?.id ?? ALL_SERVERS_ID) }, true)
|
||
}).catch(e => { if (!ac.signal.aborted) setError(e instanceof z.ZodError ? "Некорректный каталог устройств" : e.message) }).finally(() => { if (!ac.signal.aborted) setLoading(false) })
|
||
return () => ac.abort()
|
||
}, [base, active, retry])
|
||
const items: ServerTileItem[] = servers.map(s => ({ id: String(s.id), name: s.name, host: s.host, country: s.country, type: s.type, status: s.status ?? "unknown", enabled: s.enabled, selectable: true }))
|
||
// Keep an explicit historical ID in the rail after deletion; history remains addressable.
|
||
if (selectedId && selectedId !== ALL_SERVERS_ID && !items.some(s => s.id === selectedId) && /^\d+$/.test(selectedId)) items.push({ id: selectedId, name: `История устройства #${selectedId}`, host: "Удалено или отсутствует в каталоге", enabled: false, selectable: true })
|
||
const selected = servers.find(s => String(s.id) === selectedId)
|
||
return <ServerRailLayout items={items} selectedId={selectedId} preserveSelection showAll allCount={servers.length} showCount={false} loading={loading} onSelect={id => navigate({ server: id, live: id === ALL_SERVERS_ID ? "0" : null })} contentClassName="min-w-0 min-h-0 overflow-y-auto p-2 md:p-3"
|
||
header={<PageHeader crumbs={[{ label: "Инструменты" }, { label: "Логи" }]} actions={<><ServerRailMobileButton /><Button size="sm" variant="outline" onClick={() => setRetry(n => n + 1)} disabled={!active || loading}><RefreshCwIcon />Обновить устройства</Button></>} />}>
|
||
{!active ? <Alert variant="info"><AlertTitle>Демонстрационный источник</AlertTitle><AlertDescription>Для просмотра сохранённых логов и live-потока выберите «Живые» в настройках источника данных.</AlertDescription></Alert> : error ? <Alert variant="destructive"><AlertTitle>Каталог устройств не подтверждён</AlertTitle><AlertDescription>{error}<Button size="sm" variant="outline" onClick={() => setRetry(n => n + 1)}>Повторить</Button></AlertDescription></Alert> : !ready || !selectedId ? <Skeleton className="h-80 w-full" /> : <LogExplorer key={JSON.stringify([base, params.toString()])} base={base} raw={params.toString()} selectedName={selected?.name ?? "История"} liveAvailable={!!selected?.enabled} />}
|
||
</ServerRailLayout>
|
||
}
|
||
function LogExplorer({ base, raw, selectedName, liveAvailable }: { base: string; raw: string; selectedName: string; liveAvailable: boolean }) {
|
||
const params = useMemo(() => new URLSearchParams(raw), [raw])
|
||
const [anchor] = useState(() => Date.now())
|
||
const range = params.get("range") ?? "15m", server = params.get("server") ?? ALL_SERVERS_ID
|
||
const live = server !== ALL_SERVERS_ID && liveAvailable && params.get("live") !== "0" && range !== "custom"
|
||
const parsed = useMemo(() => {
|
||
const from = range === "custom" ? params.get("from") : new Date(anchor - (RANGE_MS[range] ?? 900000)).toISOString()
|
||
return logFiltersSchema.safeParse({ serverId: server === ALL_SERVERS_ID ? undefined : server, from: from || undefined, to: range === "custom" ? params.get("to") || undefined : live ? undefined : new Date(anchor).toISOString(), levels: params.get("levels") ?? "", topics: params.get("topics") ?? "", q: params.get("q") ?? "", preset: params.get("preset") ?? "" })
|
||
}, [params, server, range, anchor, live])
|
||
const query = useMemo(() => { const query = new URLSearchParams(); if (parsed.success) for (const [key, value] of Object.entries(parsed.data)) if (value !== undefined && value !== "") query.set(key, String(value)); return query.toString() }, [parsed])
|
||
const [paused, setPaused] = useState(false), [selected, setSelected] = useState<LogEntry | null>(null)
|
||
const valid = parsed.success && RANGES.some(r => r.value === range) && (range !== "custom" || !!params.get("from") && !!params.get("to"))
|
||
const result = useRouterLogs(base, query, valid, live, paused || !!selected, live ? RANGE_MS[range] ?? 900000 : 0)
|
||
const [search, setSearch] = useState(params.get("q") ?? "")
|
||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() => createFilterQuery(["levels", "topics"].flatMap(field => params.get(field) ? [createFilterRule({ id: field, path: [field], operator: "is_any_of", value: params.get(field)!.split(",") })] : [])))
|
||
const [viewName, setViewName] = useState(""), [views, setViews] = useState<z.infer<typeof viewSchema>>([]), [exporting, setExporting] = useState(false)
|
||
const exportController = useRef<AbortController | null>(null)
|
||
const storageKey = `mm:logs:views:${base}:${getClaims()?.sub ?? "local"}`
|
||
useEffect(() => { queueMicrotask(() => { try { setViews(viewSchema.parse(JSON.parse(localStorage.getItem(storageKey) ?? "[]"))) } catch { setViews([]) } }); return () => exportController.current?.abort() }, [storageKey])
|
||
const fields: FilterField[] = useMemo(() => [
|
||
{ id: "levels", label: "Уровень", type: "multiselect", operators: [{ value: "is_any_of", label: "любой из", arity: "many" }], defaultOperator: "is_any_of", options: logLevels.map(value => ({ value, label: LEVEL_LABELS[value] })) },
|
||
{ id: "topics", label: "Topics", type: "multiselect", operators: [{ value: "is_any_of", label: "любой из", arity: "many" }], defaultOperator: "is_any_of", options: [...new Set(["system", "account", "interface", "firewall", "route", "routing", "bgp", "ospf", "bfd", "dhcp", "dns", "wireguard", "ipsec", "script", ...result.entries.flatMap(row => row.topics), ...(params.get("topics")?.split(",") ?? [])])].sort().map(value => ({ value, label: value })) },
|
||
], [result.entries, params])
|
||
const applyFilters = (next: FilterQuery) => {
|
||
setFilterQuery(next)
|
||
const conditions = flattenFilterConditions(next), values: Record<string, string | null> = { levels: null, topics: null }
|
||
for (const condition of conditions) if (condition.values.length) values[condition.field] = condition.values.join(",")
|
||
navigate(values)
|
||
}
|
||
const reset = () => navigate({ q: null, levels: null, topics: null, preset: null, range: "15m", from: null, to: null })
|
||
const openEntry = useCallback((entry: LogEntry) => setSelected(entry), [])
|
||
const fetchMore = result.loadMore
|
||
const loadMore = useCallback(() => { setPaused(true); void fetchMore() }, [fetchMore])
|
||
const pause = useCallback(() => setPaused(true), [])
|
||
const state = result.stream?.state ?? result.summary?.states.find(s => String(s.serverId) === server)
|
||
const collectorEnabled = result.stream?.enabled ?? result.summary?.settings.enabled
|
||
const saveView = () => {
|
||
if (!viewName.trim()) return
|
||
const savedQuery = new URLSearchParams(raw); savedQuery.delete("server")
|
||
const next = [...views.filter(v => v.name !== viewName.trim()), { name: viewName.trim().slice(0, 60), query: savedQuery.toString() }].slice(-20)
|
||
try { localStorage.setItem(storageKey, JSON.stringify(next)); setViews(next); setViewName(""); toast.success("Представление сохранено") } catch { toast.error("Не удалось сохранить представление") }
|
||
}
|
||
const doExport = async (format: "csv" | "json") => {
|
||
const ac = new AbortController(); exportController.current = ac; setExporting(true)
|
||
const exportQuery = new URLSearchParams(query)
|
||
if (live) exportQuery.set("from", new Date(Date.now() - (RANGE_MS[range] ?? 900000)).toISOString())
|
||
try { await exportLogs(base, exportQuery, format, ac.signal) } catch (e) { if (!ac.signal.aborted) toast.error(e instanceof Error ? e.message : "Экспорт не завершён") } finally { if (!ac.signal.aborted) setExporting(false) }
|
||
}
|
||
if (!parsed.success || !RANGES.some(r => r.value === range) || range === "custom" && (!params.get("from") || !params.get("to"))) return <Alert variant="destructive"><AlertTitle>Некорректные фильтры в ссылке</AlertTitle><AlertDescription>Проверьте устройство, уровни и период.<Button size="sm" variant="outline" onClick={() => navigate({ server: ALL_SERVERS_ID, q: null, levels: null, topics: null, preset: null, range: null, from: null, to: null })}>Сбросить ссылку</Button></AlertDescription></Alert>
|
||
return <div className="flex min-w-0 flex-col gap-4">
|
||
<div className="flex flex-wrap items-center justify-between gap-3"><div className="flex min-w-0 flex-col gap-0.5"><h1 className="text-lg font-semibold tracking-tight">Логи MikroTik</h1><p className="text-sm text-muted-foreground">{server === ALL_SERVERS_ID ? "Все серверы · сохранённая история" : selectedName}</p></div><div className="flex flex-wrap items-center gap-2">
|
||
<Select value={range} items={RANGES} onValueChange={value => { if (!value) return; navigate(value === "custom" ? { range: value, from: new Date(anchor - 900000).toISOString(), to: new Date(anchor).toISOString(), live: "0" } : { range: value, from: null, to: null }) }}><SelectTrigger size="sm" aria-label="Период логов"><SelectValue /></SelectTrigger><SelectContent>{RANGES.map(r => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}</SelectContent></Select>
|
||
<Button size="sm" variant={live ? "default" : "outline"} disabled={!liveAvailable || range === "custom"} onClick={() => navigate({ live: live ? "0" : null })}><ActivityIcon />{live ? "Live включён" : "История"}</Button><Button size="sm" variant="outline" onClick={result.reload}><RefreshCwIcon />Обновить</Button>
|
||
<Button size="sm" variant="outline" disabled={exporting} onClick={() => void doExport("csv")}><DownloadIcon />CSV</Button><Button size="sm" variant="outline" disabled={exporting} onClick={() => void doExport("json")}>JSON</Button>
|
||
</div></div>
|
||
{range === "custom" && <div className="flex flex-wrap gap-3">{["from", "to"].map(key => <label key={key} className="flex min-w-0 flex-col gap-1 text-xs">{key === "from" ? "Начало (UTC)" : "Конец (UTC)"}<Input type="datetime-local" aria-label={key === "from" ? "Начало периода UTC" : "Конец периода UTC"} value={params.get(key)?.slice(0, 16) ?? ""} onChange={event => { if (event.target.value) navigate({ [key]: new Date(event.target.value + "Z").toISOString() }) }} /></label>)}</div>}
|
||
<div role="status" className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"><Badge variant={!collectorEnabled ? "secondary" : result.streamError || state?.lastError ? "warning-light" : live && result.stream ? "success-light" : "info-light"}>{collectorEnabled === undefined ? "Проверяем сборщик" : !collectorEnabled ? "Сборщик выключен" : result.streamError ? "Переподключение" : state?.lastError ? "Ошибка сбора" : live && result.stream ? "Live подключён" : "История"}</Badge><span>Последний сбор: {formatLogTime(state?.lastCollectedAt ?? result.summary?.states.map(s => s.lastCollectedAt).filter((s): s is string => !!s).sort().at(-1) ?? null)}</span>{live && <Button size="sm" variant="outline" onClick={() => { if (paused) setPaused(false); else setPaused(true) }}>{paused ? <PlayIcon /> : <PauseIcon />}{paused ? `Новых событий: ${result.pendingCount}` : "Пауза"}</Button>}</div>
|
||
{collectorEnabled === false && <Alert variant="info"><AlertTitle>История доступна, сбор новых событий выключен</AlertTitle><AlertDescription>Администратор может включить коллектор в разделе <Link className="underline" href="/data-collection#router-logs">«Сбор данных»</Link>.</AlertDescription></Alert>}
|
||
{(result.streamError || state?.lastError) && <Alert variant="warning"><AlertTitle>Live требует внимания</AlertTitle><AlertDescription>{state?.lastError ?? result.streamError}</AlertDescription></Alert>}
|
||
{state?.gapAt && <Alert variant="warning"><AlertTitle>Возможен разрыв истории</AlertTitle><AlertDescription>Буфер изменился без пересечения с предыдущим снимком: {formatLogTime(state.gapAt)}. Количество недоступных событий неизвестно.</AlertDescription></Alert>}
|
||
<Frame dense><FrameHeader className="flex-row flex-wrap items-center justify-between gap-3"><div className="flex flex-col gap-0.5"><FrameTitle>Поток событий</FrameTitle><FrameDescription>Выберите столбец, чтобы открыть его период</FrameDescription></div><div className="flex flex-wrap gap-2">{[["Событий", result.summary?.total], ["Ошибок", result.summary ? result.summary.levels.error + result.summary.levels.critical : undefined], ["Предупреждений", result.summary?.levels.warning]].map(([label, count]) => <span key={label} className="text-xs text-muted-foreground">{label}: <strong className="text-foreground tabular-nums">{count?.toLocaleString("ru-RU") ?? "—"}</strong></span>)}</div></FrameHeader><FramePanel>
|
||
{result.summaryError ? <Alert variant="warning"><AlertDescription>{result.summaryError}<Button size="sm" variant="outline" onClick={result.reload}>Повторить</Button></AlertDescription></Alert> : !result.summary ? <Skeleton className="h-32 w-full" /> : result.summary.buckets.length === 0 ? <div className="flex h-32 items-center justify-center text-sm text-muted-foreground">За выбранный период событий нет</div> : <ChartContainer className="h-36 w-full" config={Object.fromEntries(logLevels.map(level => [level, { label: LEVEL_LABELS[level], color: LEVEL_COLORS[level] }]))}>
|
||
<BarChart accessibilityLayer data={result.summary.buckets} onClick={value => { const at = value.activeLabel; if (!at || !result.summary) return; const buckets = result.summary.buckets, i = buckets.findIndex(b => b.at === at); if (i < 0) return; const width = Math.max(1000, Math.ceil(((parsed.data.to ? Date.parse(parsed.data.to) : anchor) - Date.parse(parsed.data.from ?? new Date(anchor - 900000).toISOString())) / 60000) * 1000); navigate({ range: "custom", from: buckets[i].at, to: new Date(Date.parse(buckets[i].at) + width - 1).toISOString(), live: "0" }) }}><XAxis dataKey="at" tickFormatter={value => new Date(value).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })} minTickGap={40} tickLine={false} axisLine={false} /><YAxis allowDecimals={false} width={32} tickLine={false} axisLine={false} /><ChartTooltip content={<ChartTooltipContent labelFormatter={value => formatLogTime(String(value))} />} />{logLevels.map(level => <Bar key={level} dataKey={level} stackId="events" fill={`var(--color-${level})`} maxBarSize={28} isAnimationActive={false} />)}</BarChart>
|
||
</ChartContainer>}
|
||
</FramePanel></Frame>
|
||
{result.error && <Alert variant="destructive"><AlertTitle>Журнал не загружен</AlertTitle><AlertDescription>{result.error}<Button size="sm" variant="outline" onClick={result.reload}>Повторить</Button></AlertDescription></Alert>}
|
||
<LogsGrid entries={result.entries} loading={result.loading} total={result.summary?.total ?? null} search={parsed.data.q} onSelect={openEntry} onPause={pause} hasMore={result.hasMore} fetchingMore={result.fetchingMore} loadMore={loadMore} reset={reset} toolbar={<>
|
||
<div className="flex flex-wrap items-center gap-2"><form className="flex min-w-0 flex-1 basis-full gap-2 sm:basis-auto sm:max-w-md" onSubmit={event => { event.preventDefault(); navigate({ q: search || null }) }}><Input maxLength={500} aria-label="Поиск по сообщениям" placeholder="Сообщение, IP, интерфейс…" value={search} onChange={event => setSearch(event.target.value)} /><Button size="sm" variant="outline" aria-label="Найти события" type="submit"><SearchIcon /></Button></form><Select value={params.get("preset") || (params.get("levels") === "critical,error" ? "errors" : params.get("levels") === "warning" ? "warnings" : "")} items={PRESETS} onValueChange={value => { navigate(value === "errors" ? { levels: "critical,error", preset: null } : value === "warnings" ? { levels: "warning", preset: null } : { preset: value || null, levels: null }) }}><SelectTrigger size="sm" aria-label="Быстрый срез"><SelectValue placeholder="Все события" /></SelectTrigger><SelectContent>{PRESETS.map(p => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}</SelectContent></Select><Button size="sm" variant="ghost" onClick={reset}>Сбросить</Button></div>
|
||
<Filters size="sm" fields={fields} query={filterQuery} labels={FILTER_LABELS_RU} onBeforeQueryChange={(next, details) => details.reason !== "negate" && next.rules.every(rule => rule.type === "rule") && new Set(next.rules.map(rule => rule.type === "rule" ? rule.path[0] : "")).size === next.rules.length} onQueryChange={applyFilters} />
|
||
<div className="flex flex-wrap items-center gap-2"><Input aria-label="Название представления" placeholder="Название представления" className="w-full sm:w-48" maxLength={60} value={viewName} onChange={event => setViewName(event.target.value)} /><Button size="sm" variant="outline" disabled={!viewName.trim()} onClick={saveView}><BookmarkPlusIcon />Сохранить</Button>{views.map(view => <Button key={view.name} size="sm" variant="ghost" onClick={() => { const saved = new URLSearchParams(view.query); saved.set("server", server); window.history.pushState(null, "", "/logs?" + saved) }}>{view.name}</Button>)}</div>
|
||
</>} />
|
||
{result.entries.length >= 5000 && <p role="status" className="text-xs text-muted-foreground">Показаны последние 5 000 загруженных событий. Для более ранних записей задайте период или используйте экспорт.</p>}
|
||
<p className="text-xs text-muted-foreground">REST читает буфер memory. Переполнение, очистка и перезагрузка MikroTik могут привести к пропускам. Сохранённая история остаётся в БД.</p>
|
||
<LogDetailSheet entry={selected} base={base} onClose={() => setSelected(null)} onSelect={openEntry} />
|
||
</div>
|
||
}
|