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

@@ -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>
}