"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 = { "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, 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 }> } function LogsRoute() { const { backendUrl, mode, prefsHydrated } = useDataSource() return prefsHydrated ? :

Загружаем источник…

} function LogsCatalog({ base, active }: { base: string; active: boolean }) { const params = useSearchParams(), selectedId = params.get("server") ?? "" const [servers, setServers] = useState>([]), [error, setError] = useState(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 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={} />}> {!active ? Демонстрационный источникДля просмотра сохранённых логов и live-потока выберите «Живые» в настройках источника данных. : error ? Каталог устройств не подтверждён{error} : !ready || !selectedId ? : } } 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(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(() => 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>([]), [exporting, setExporting] = useState(false) const exportController = useRef(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 = { 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 Некорректные фильтры в ссылкеПроверьте устройство, уровни и период. return

Логи MikroTik

{server === ALL_SERVERS_ID ? "Все серверы · сохранённая история" : selectedName}

{range === "custom" &&
{["from", "to"].map(key => )}
}
{collectorEnabled === undefined ? "Проверяем сборщик" : !collectorEnabled ? "Сборщик выключен" : result.streamError ? "Переподключение" : state?.lastError ? "Ошибка сбора" : live && result.stream ? "Live подключён" : "История"}Последний сбор: {formatLogTime(state?.lastCollectedAt ?? result.summary?.states.map(s => s.lastCollectedAt).filter((s): s is string => !!s).sort().at(-1) ?? null)}{live && }
{collectorEnabled === false && История доступна, сбор новых событий выключенАдминистратор может включить коллектор в разделе «Сбор данных».} {(result.streamError || state?.lastError) && Live требует внимания{state?.lastError ?? result.streamError}} {state?.gapAt && Возможен разрыв историиБуфер изменился без пересечения с предыдущим снимком: {formatLogTime(state.gapAt)}. Количество недоступных событий неизвестно.}
Поток событийВыберите столбец, чтобы открыть его период
{[["Событий", result.summary?.total], ["Ошибок", result.summary ? result.summary.levels.error + result.summary.levels.critical : undefined], ["Предупреждений", result.summary?.levels.warning]].map(([label, count]) => {label}: {count?.toLocaleString("ru-RU") ?? "—"})}
{result.summaryError ? {result.summaryError} : !result.summary ? : result.summary.buckets.length === 0 ?
За выбранный период событий нет
: [level, { label: LEVEL_LABELS[level], color: LEVEL_COLORS[level] }]))}> { 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" }) }}> new Date(value).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })} minTickGap={40} tickLine={false} axisLine={false} /> formatLogTime(String(value))} />} />{logLevels.map(level => )} }
{result.error && Журнал не загружен{result.error}}
{ event.preventDefault(); navigate({ q: search || null }) }}> setSearch(event.target.value)} />
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} />
setViewName(event.target.value)} />{views.map(view => )}
} /> {result.entries.length >= 5000 &&

Показаны последние 5 000 загруженных событий. Для более ранних записей задайте период или используйте экспорт.

}

REST читает буфер memory. Переполнение, очистка и перезагрузка MikroTik могут привести к пропускам. Сохранённая история остаётся в БД.

setSelected(null)} onSelect={openEntry} />
}