> }
diff --git a/components/logs/log-presentation.tsx b/components/logs/log-presentation.tsx
new file mode 100644
index 00000000..08dd88c9
--- /dev/null
+++ b/components/logs/log-presentation.tsx
@@ -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 = { critical: "Критическая", error: "Ошибка", warning: "Предупреждение", info: "Информация", debug: "Отладка", unknown: "Не определён" }
+export const LEVEL_COLORS: Record = { 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 {LEVEL_LABELS[level]}
+}
+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({text.slice(start, found)}{text.slice(found, found + search.length)})
+ 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" }) : "—" }
diff --git a/components/logs/logs-collector-settings.tsx b/components/logs/logs-collector-settings.tsx
new file mode 100644
index 00000000..cda7e8b7
--- /dev/null
+++ b/components/logs/logs-collector-settings.tsx
@@ -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(null), [summary, setSummary] = useState(null)
+ const [interval, setIntervalDraft] = useState("10"), [retention, setRetention] = useState("14"), [enabled, setEnabled] = useState(false)
+ const [error, setError] = useState(null), [saving, setSaving] = useState(false), [revision, setRevision] = useState(0), [notice, setNotice] = useState("")
+ const lifetime = useRef(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 Логи MikroTikREST-сбор буфера memory, сохранение в PostgreSQL. При открытом live опрос выбранного устройства ускоряется до 2 секунд.
+ {error && Данные сборщика не подтверждены{error}}
+ {!settings && !error && }
+
+ {notice &&
{notice}
}
+
Интервал: 5–3600 секунд, хранение: 1–365 дней. Буфер MikroTik может перезаписаться между опросами. Новые правила логирования на устройствах не создаются.
+ {collectorEnabled === false && История доступна, сбор новых событий выключенАдминистратор может включить коллектор в разделе «Сбор данных».}
+ {(result.streamError || state?.lastError) && Live требует внимания{state?.lastError ?? result.streamError}}
+ {state?.gapAt && Возможен разрыв историиБуфер изменился без пересечения с предыдущим снимком: {formatLogTime(state.gapAt)}. Количество недоступных событий неизвестно.}
+
Поток событийВыберите столбец, чтобы открыть его период