feat(logs): добавить раздел логов с историей и живым потоком
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

This commit is contained in:
Denozordec committed 2026-10-16 22:13:48 +07:00
1 parent e461dc21bd
commit 5efd0339fb
37 files changed
+1399 -7

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
"use client"
// Adapted from ReUI PRO sheet-14: event header, facts, tabs and stepping context.
// https://reui.io/preview/base/sheet-14
import { useEffect, useState } from "react"
import Link from "next/link"
import { z } from "zod"
import type { LogEntry } from "@mmapp/contracts"
import { toast } from "sonner"
import { CopyIcon, ExternalLinkIcon } from "lucide-react"
import { Sheet, SheetContent, SheetTitle, SheetDescription, SheetHeader } from "@/components/ui/sheet"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { getLogContext } from "@/shared/api/logs"
import { LevelBadge, formatLogTime } from "./log-presentation"
export function LogDetailSheet({ entry, base, onClose, onSelect }: { entry: LogEntry | null; base: string; onClose: () => void; onSelect: (entry: LogEntry) => void }) {
return <Sheet open={!!entry} onOpenChange={open => { if (!open) onClose() }}><SheetContent className="data-[side=right]:w-full data-[side=right]:sm:max-w-2xl overflow-y-auto">
{entry && <LogDetail key={entry.id} entry={entry} base={base} onSelect={onSelect} />}
</SheetContent></Sheet>
}
function LogDetail({ entry, base, onSelect }: { entry: LogEntry; base: string; onSelect: (entry: LogEntry) => void }) {
const [context, setContext] = useState<Awaited<ReturnType<typeof getLogContext>> | null>(null), [error, setError] = useState<string | null>(null), [retry, setRetry] = useState(0)
useEffect(() => {
const ac = new AbortController()
void getLogContext(base, entry.id, ac.signal).then(data => { if (!ac.signal.aborted) { setContext(data); setError(null) } }).catch(e => { if (!ac.signal.aborted) setError(e instanceof z.ZodError ? "Некорректный ответ контекста" : e.message) })
return () => ac.abort()
}, [base, entry.id, retry])
const copy = () => { void navigator.clipboard.writeText(JSON.stringify(entry, null, 2)).then(() => toast.success("Событие скопировано"), () => toast.error("Не удалось скопировать событие")) }
return <>
<SheetHeader className="gap-3 border-b">
<div className="flex flex-wrap items-center gap-2"><LevelBadge level={entry.level} /><span className="text-xs text-muted-foreground font-mono">{entry.routerId}</span></div>
<SheetTitle>Событие RouterOS</SheetTitle><SheetDescription>{entry.serverName} · {entry.host} · {formatLogTime(entry.effectiveAt)}</SheetDescription>
<div className="flex flex-wrap gap-2"><Button size="sm" variant="outline" onClick={copy}><CopyIcon />Копировать</Button><Button size="sm" variant="outline" nativeButton={false} render={<Link href={`/servers?server=${entry.serverId}`} />}><ExternalLinkIcon />Устройство</Button></div>
</SheetHeader>
<Tabs defaultValue="details" className="min-w-0 px-4 pb-4">
<TabsList variant="line"><TabsTrigger value="details">Событие</TabsTrigger><TabsTrigger value="raw">Исходные поля</TabsTrigger><TabsTrigger value="context">Контекст</TabsTrigger></TabsList>
<TabsContent value="details" className="flex flex-col gap-4">
<p className="whitespace-pre-wrap break-words font-mono text-sm">{entry.message}</p>
<dl className="grid grid-cols-1 sm:grid-cols-[auto_1fr] gap-x-6 gap-y-3 text-sm">
{[["Topics", entry.topics.join(", ")], ["Время RouterOS", entry.rawTime], ["Получено", formatLogTime(entry.receivedAt)], ["Источник времени", entry.timeKind === "receipt" ? "Время получения: дата или часовой пояс RouterOS неоднозначны" : "Время RouterOS"], ["Поколение буфера", String(entry.generation)]].map(([label, value]) => <FragmentPair key={label} label={label} value={value} />)}
</dl>
</TabsContent>
<TabsContent value="raw"><pre className="overflow-auto whitespace-pre-wrap break-all text-xs font-mono">{JSON.stringify({ ".id": entry.routerId, time: entry.rawTime, topics: entry.topics.join(","), message: entry.message }, null, 2)}</pre></TabsContent>
<TabsContent value="context" className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">До 10 событий до и после записи на этом MikroTik. Фильтры журнала здесь не применяются.</p>
{error ? <Alert variant="destructive"><AlertTitle>Контекст не получен</AlertTitle><AlertDescription>{error}<Button size="sm" variant="outline" onClick={() => setRetry(n => n + 1)}>Повторить</Button></AlertDescription></Alert> : !context ? <Skeleton className="h-32 w-full" /> : [context.before, [context.entry], context.after].flat().map(row => <Button key={row.id} variant="ghost" className="h-auto justify-start whitespace-normal text-left" onClick={() => onSelect(row)} aria-current={row.id === entry.id ? "true" : undefined}><span className="flex min-w-0 flex-col gap-1"><span className="text-xs text-muted-foreground tabular-nums">{formatLogTime(row.effectiveAt)} · {row.topics.join(", ")}</span><span className="break-all font-mono text-xs">{row.message}</span></span></Button>)}
</TabsContent>
</Tabs>
</>
}
function FragmentPair({ label, value }: { label: string; value: string }) { return <><dt className="text-muted-foreground">{label}</dt><dd className="min-w-0 break-words">{value}</dd></> }
+23
View File
@@ -0,0 +1,23 @@
"use client"
import { Fragment } from "react"
import { AlertTriangleIcon, CircleAlertIcon, CircleHelpIcon, InfoIcon, BugIcon } from "lucide-react"
import { Badge } from "@/components/reui/badge"
import type { LogLevel } from "@mmapp/contracts"
export const LEVEL_LABELS: Record<LogLevel, string> = { critical: "Критическая", error: "Ошибка", warning: "Предупреждение", info: "Информация", debug: "Отладка", unknown: "Не определён" }
export const LEVEL_COLORS: Record<LogLevel, string> = { critical: "var(--destructive)", error: "var(--destructive)", warning: "var(--warning)", info: "var(--info)", debug: "var(--muted-foreground)", unknown: "var(--border)" }
export function LevelBadge({ level }: { level: LogLevel }) {
const Icon = level === "critical" || level === "error" ? CircleAlertIcon : level === "warning" ? AlertTriangleIcon : level === "debug" ? BugIcon : level === "unknown" ? CircleHelpIcon : InfoIcon
return <Badge variant={level === "critical" || level === "error" ? "destructive-light" : level === "warning" ? "warning-light" : level === "info" ? "info-light" : "secondary"} size="sm"><Icon aria-hidden="true" className="size-3" />{LEVEL_LABELS[level]}</Badge>
}
export function HighlightMessage({ text, search }: { text: string; search: string }) {
if (!search) return <>{text}</>
const lower = text.toLocaleLowerCase(), needle = search.toLocaleLowerCase(), parts = []
let start = 0, found = lower.indexOf(needle)
while (found >= 0) {
parts.push(<Fragment key={start}>{text.slice(start, found)}<mark className="bg-primary/15 text-foreground">{text.slice(found, found + search.length)}</mark></Fragment>)
start = found + search.length; found = lower.indexOf(needle, start)
}
return <>{parts}{text.slice(start)}</>
}
export function formatLogTime(at: string | null) { return at ? new Date(at).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "—" }
@@ -0,0 +1,54 @@
"use client"
import { useEffect, useRef, useState } from "react"
import Link from "next/link"
import { type LogSettings, type LogSummary } from "@mmapp/contracts"
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Skeleton } from "@/components/ui/skeleton"
import { getLogSettings, getLogSummary, putLogSettings } from "@/shared/api/logs"
import { can, ensureAuthConfig } from "@/lib/auth"
import { formatLogTime } from "./log-presentation"
export function LogsCollectorSettings({ base, onSaved }: { base: string; onSaved?: (settings: LogSettings) => void }) {
const [settings, setSettings] = useState<LogSettings | null>(null), [summary, setSummary] = useState<LogSummary | null>(null)
const [interval, setIntervalDraft] = useState("10"), [retention, setRetention] = useState("14"), [enabled, setEnabled] = useState(false)
const [error, setError] = useState<string | null>(null), [saving, setSaving] = useState(false), [revision, setRevision] = useState(0), [notice, setNotice] = useState("")
const lifetime = useRef<AbortController | null>(null), busy = useRef(false)
useEffect(() => {
const ac = new AbortController(); lifetime.current = ac
void ensureAuthConfig().then(async () => {
if (!can("mm:settings:admin")) throw new Error("Настройки логов доступны с mm:settings:admin")
const data = await getLogSettings(base, ac.signal)
if (ac.signal.aborted) return
setSettings(data); setEnabled(data.enabled); setIntervalDraft(String(data.intervalSec)); setRetention(String(data.retentionDays)); setError(null)
const stats = await getLogSummary(base, new URLSearchParams({ from: new Date(Date.now() - 900000).toISOString() }), ac.signal)
if (!ac.signal.aborted) setSummary(stats)
}).catch(e => { if (!ac.signal.aborted) setError(e instanceof Error ? e.message : String(e)) })
return () => ac.abort()
}, [base, revision])
const save = async () => {
if (busy.current || !settings || !can("mm:settings:admin")) return
if (!/^\d+$/.test(interval) || !/^\d+$/.test(retention)) { setError("Укажите целые значения интервала и срока хранения"); return }
if (Number(interval) < 5 || Number(interval) > 3600 || Number(retention) < 1 || Number(retention) > 365) { setError("Интервал должен быть от 5 до 3600 секунд, срок хранения — от 1 до 365 дней"); return }
busy.current = true; setSaving(true); setNotice("")
const signal = lifetime.current?.signal
try {
const confirmed = await putLogSettings(base, { enabled, intervalSec: Number(interval), retentionDays: Number(retention) }, signal)
if (signal?.aborted) return
setSettings(confirmed); setError(null); setNotice("Настройки сборщика логов сохранены"); onSaved?.(confirmed)
} catch (e) { if (!signal?.aborted) setError(e instanceof Error ? e.message : String(e)) }
finally { busy.current = false; if (!signal?.aborted) setSaving(false) }
}
return <Frame id="router-logs" dense><FrameHeader><FrameTitle>Логи MikroTik</FrameTitle><FrameDescription>REST-сбор буфера memory, сохранение в PostgreSQL. При открытом live опрос выбранного устройства ускоряется до 2 секунд.</FrameDescription></FrameHeader><FramePanel className="flex flex-col gap-4">
{error && <Alert variant="destructive"><AlertTitle>Данные сборщика не подтверждены</AlertTitle><AlertDescription>{error}<Button size="sm" variant="outline" disabled={saving} onClick={() => setRevision(n => n + 1)}>Повторить загрузку</Button></AlertDescription></Alert>}
{!settings && !error && <Skeleton className="h-24 w-full" />}
<div className="flex flex-wrap items-end gap-4"><label className="flex items-center gap-2 text-sm"><Switch checked={enabled} onCheckedChange={setEnabled} disabled={!settings || saving} aria-label="Включить сбор логов" />Собирать логи</label><label className="flex flex-col gap-1 text-xs">Фоновый интервал, секунд<Input aria-label="Интервал сбора логов" className="w-40" inputMode="numeric" value={interval} onChange={e => setIntervalDraft(e.target.value)} disabled={!settings || saving} /></label><label className="flex flex-col gap-1 text-xs">Хранить, дней<Input aria-label="Срок хранения логов" className="w-40" inputMode="numeric" value={retention} onChange={e => setRetention(e.target.value)} disabled={!settings || saving} /></label><Button size="sm" disabled={!settings || saving} onClick={() => void save()}>{saving ? "Сохранение…" : "Сохранить настройки логов"}</Button></div>
{notice && <p role="status" className="text-sm">{notice}</p>}
<p className="text-xs text-muted-foreground">Интервал: 5–3600 секунд, хранение: 1–365 дней. Буфер MikroTik может перезаписаться между опросами. Новые правила логирования на устройствах не создаются.</p>
{summary?.states.length ? <div className="flex flex-col gap-2">{summary.states.map(state => <div key={state.serverId} className="flex flex-wrap items-center gap-2 border-t py-2 text-xs"><Link href={`/logs?server=${state.serverId}`} className="font-medium underline">{state.serverName}</Link><Badge size="sm" variant={state.lastError ? "warning-light" : state.lastCollectedAt ? "success-light" : "secondary"}>{state.lastError ? "Ошибка" : state.lastCollectedAt ? "Получено" : "Ожидание"}</Badge><span className="text-muted-foreground">{formatLogTime(state.lastCollectedAt)}</span>{state.gapAt && <span>Возможный разрыв: {formatLogTime(state.gapAt)}</span>}{state.lastError && <span role="status" className="break-all">{state.lastError}</span>}</div>)}</div> : <p className="text-xs text-muted-foreground">Результатов сбора по устройствам пока нет.</p>}
</FramePanel></Frame>
}
+52
View File
@@ -0,0 +1,52 @@
"use client"
// ReUI PRO solution-dev-ops-2: Frame-led grid, toolbar and display settings.
// https://reui.io/preview/base/solution-dev-ops-2
import { useEffect, useMemo, useState, type ReactNode } from "react"
import { useTable, type ColumnDef } from "@tanstack/react-table"
import { elementScroll, observeElementRect, observeElementOffset } from "@tanstack/react-virtual"
import type { LogEntry } from "@mmapp/contracts"
import { CopyIcon, Columns3Icon, WrapTextIcon } from "lucide-react"
import { toast } from "sonner"
import { DataGrid, DataGridContainer, DataGridTableVirtual, DataGridColumnVisibility, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame"
import { Button } from "@/components/ui/button"
import { DATA_GRID_LABELS_RU } from "@/lib/data-grid-labels-ru"
import { HighlightMessage, LevelBadge, formatLogTime } from "./log-presentation"
export function LogsGrid({ entries, loading, total, search, toolbar, onSelect, onPause, hasMore, fetchingMore, loadMore, reset }: {
entries: LogEntry[]; loading: boolean; total: number | null; search: string; toolbar: ReactNode;
onSelect: (entry: LogEntry) => void; onPause: () => void; hasMore: boolean; fetchingMore: boolean; loadMore: () => void; reset: () => void
}) {
const [wrap, setWrap] = useState(false)
const [narrow, setNarrow] = useState(false)
useEffect(() => {
const mq = window.matchMedia("(max-width: 767px)")
const update = () => setNarrow(mq.matches)
queueMicrotask(update); mq.addEventListener("change", update)
return () => mq.removeEventListener("change", update)
}, [])
const columns = useMemo<ColumnDef<DataGridFeatures, LogEntry>[]>(() => [
{ accessorKey: "effectiveAt", header: "Время", size: 165, cell: ({ row }) => <span className="font-mono text-xs tabular-nums" title={row.original.timeKind === "receipt" ? `Время получения; RouterOS: ${row.original.rawTime}` : row.original.rawTime}>{formatLogTime(row.original.effectiveAt)}{row.original.timeKind === "receipt" && <span aria-label="Время получения"> *</span>}</span> },
{ accessorKey: "serverName", header: "Устройство", size: 150, cell: ({ row }) => <span className="truncate" title={row.original.host}>{row.original.serverName}</span> },
{ accessorKey: "level", header: "Уровень", size: 165, cell: ({ row }) => <LevelBadge level={row.original.level} /> },
{ accessorKey: "topics", header: "Topics", size: 180, cell: ({ row }) => <span className="truncate text-xs text-muted-foreground font-mono" title={row.original.topics.join(", ")}>{row.original.topics.join(", ")}</span> },
{ accessorKey: "message", header: narrow ? "Событие" : "Сообщение", size: narrow ? 300 : 600, enableHiding: false, cell: ({ row }) => <Button variant="ghost" className="h-auto min-w-0 w-full justify-start whitespace-normal text-left" onClick={event => { event.stopPropagation(); onSelect(row.original) }} aria-label={`Открыть событие ${row.original.id}`}><span className="flex min-w-0 flex-col gap-1">{narrow && <span className="flex flex-wrap items-center gap-1"><LevelBadge level={row.original.level} /><span className="text-xs text-muted-foreground tabular-nums">{formatLogTime(row.original.effectiveAt)}</span></span>}<span className={`min-w-0 font-mono text-xs ${wrap || narrow ? "whitespace-pre-wrap break-words" : "truncate"}`}><HighlightMessage text={row.original.message} search={search} /></span></span></Button> },
{ id: "copy", header: "", size: 46, enableHiding: false, cell: ({ row }) => <Button size="icon-sm" variant="ghost" aria-label="Копировать сообщение" onClick={event => { event.stopPropagation(); void navigator.clipboard.writeText(row.original.message).then(() => toast.success("Сообщение скопировано"), () => toast.error("Не удалось скопировать сообщение")) }}><CopyIcon /></Button> },
], [wrap, narrow, search, onSelect])
const table = useTable({ features: dataGridFeatures, data: entries, columns, getRowId: row => row.id, manualPagination: true, rowCount: entries.length })
useEffect(() => { table.setColumnVisibility({ effectiveAt: !narrow, serverName: !narrow, level: !narrow, topics: !narrow, copy: !narrow }) }, [narrow, table])
return <DataGrid table={table} recordCount={entries.length} isLoading={loading} loadingMode="skeleton" i18n={{ labels: DATA_GRID_LABELS_RU }} onRowClick={onSelect}
tableLayout={{ dense: true, headerSticky: true, columnsVisibility: true, width: "fixed" }}
emptyMessage={<div className="flex flex-col items-center gap-2 p-6"><p>Событий за выбранный период нет</p><Button size="sm" variant="outline" onClick={reset}>Сбросить фильтры</Button></div>}
tableClassNames={{ edgeCell: "first:ps-(--frame-panel-header-px) last:pe-(--frame-panel-header-px)" }}>
<Frame dense className="min-w-0 w-full">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3"><div className="flex min-w-0 flex-col gap-0.5"><FrameTitle>Журнал событий</FrameTitle><FrameDescription className="text-xs tabular-nums">Загружено {entries.length.toLocaleString("ru-RU")}{total !== null ? ` · Найдено ${total.toLocaleString("ru-RU")}` : ""}</FrameDescription></div><div className="flex items-center gap-2"><Button size="sm" variant="outline" aria-pressed={wrap} onClick={() => setWrap(v => !v)}><WrapTextIcon /><span className="max-sm:sr-only">Перенос</span></Button><DataGridColumnVisibility table={table} trigger={<Button variant="outline" size="sm"><Columns3Icon />Колонки</Button>} /></div></FrameHeader>
<FramePanel className="p-0"><div className="flex flex-col gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">{toolbar}</div>
<div onScrollCapture={event => { if ((event.target as HTMLElement).scrollTop > 16) onPause() }}>
<DataGridContainer border={false}><DataGridTableVirtual height={420} estimateSize={48} overscan={8} hasMore={hasMore} isFetchingMore={fetchingMore} onFetchMore={loadMore} virtualizerOptions={{ scrollToFn: elementScroll, observeElementRect, observeElementOffset, measureElement: element => element.getBoundingClientRect().height }} /></DataGridContainer>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t p-3 text-xs text-muted-foreground"><span>* Использовано время получения. Часовой пояс отображения: браузер.</span>{hasMore && <Button size="sm" variant="outline" disabled={fetchingMore} onClick={loadMore}>{fetchingMore ? "Загрузка…" : "Загрузить ещё"}</Button>}</div>
</FramePanel>
</Frame>
</DataGrid>
}
+149
View File
@@ -0,0 +1,149 @@
"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>
}