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
54 lines
5.5 KiB
TypeScript
54 lines
5.5 KiB
TypeScript
"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></> }
|