feat: integrate sonner for toast notifications and enhance UI feedback
Added the sonner library for toast notifications across various components, improving user feedback for actions such as saving settings, syncing rules, and handling errors. Updated the layout to include a Toaster component for consistent notification display. Refactored alert messages in the backups, gre, and filters pages to utilize the new notification system, enhancing overall user experience.
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
|||||||
import { Flag, countryName } from "@/components/flag"
|
import { Flag, countryName } from "@/components/flag"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1722,7 +1723,6 @@ function AddRuleSheet({
|
|||||||
() => !(initialForm?.targets?.length),
|
() => !(initialForm?.targets?.length),
|
||||||
)
|
)
|
||||||
const [testTelegramBusy, setTestTelegramBusy] = useState(false)
|
const [testTelegramBusy, setTestTelegramBusy] = useState(false)
|
||||||
const [testTelegramHint, setTestTelegramHint] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const targets = typeTargets[form.type]
|
const targets = typeTargets[form.type]
|
||||||
const conditions = TYPE_CONDITIONS[form.type]
|
const conditions = TYPE_CONDITIONS[form.type]
|
||||||
@@ -1817,7 +1817,6 @@ function AddRuleSheet({
|
|||||||
const handleTestRuleTelegram = async () => {
|
const handleTestRuleTelegram = async () => {
|
||||||
if (!onTestRuleTelegram || !canSave || testTelegramBusy || testTelegramDisabled) return
|
if (!onTestRuleTelegram || !canSave || testTelegramBusy || testTelegramDisabled) return
|
||||||
setTestTelegramBusy(true)
|
setTestTelegramBusy(true)
|
||||||
setTestTelegramHint(null)
|
|
||||||
try {
|
try {
|
||||||
const conditionLine = conditionDisplay || summarizeConditionsUi(form.conditions)
|
const conditionLine = conditionDisplay || summarizeConditionsUi(form.conditions)
|
||||||
await onTestRuleTelegram({
|
await onTestRuleTelegram({
|
||||||
@@ -1828,10 +1827,9 @@ function AddRuleSheet({
|
|||||||
cooldown: form.cooldown,
|
cooldown: form.cooldown,
|
||||||
ruleChatId: form.chatId.trim(),
|
ruleChatId: form.chatId.trim(),
|
||||||
})
|
})
|
||||||
setTestTelegramHint("__ok__")
|
toast.success("Тестовое сообщение отправлено в Telegram.")
|
||||||
window.setTimeout(() => setTestTelegramHint(null), 5000)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setTestTelegramHint(e instanceof Error ? e.message : "Не удалось отправить тест")
|
toast.error(e instanceof Error ? e.message : "Не удалось отправить тест")
|
||||||
} finally {
|
} finally {
|
||||||
setTestTelegramBusy(false)
|
setTestTelegramBusy(false)
|
||||||
}
|
}
|
||||||
@@ -2327,21 +2325,6 @@ function AddRuleSheet({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLive && onTestRuleTelegram && testTelegramHint ? (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 px-6 py-2.5 text-xs border-t",
|
|
||||||
testTelegramHint === "__ok__"
|
|
||||||
? "text-emerald-700 dark:text-emerald-400 bg-emerald-500/10 border-emerald-500/20"
|
|
||||||
: "text-destructive bg-destructive/5 border-destructive/20",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{testTelegramHint === "__ok__"
|
|
||||||
? "Тестовое сообщение отправлено в Telegram."
|
|
||||||
: testTelegramHint}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* ── fixed footer ── */}
|
{/* ── fixed footer ── */}
|
||||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-stretch">
|
<SheetFooter className="shrink-0 px-6 py-4 border-t flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-stretch">
|
||||||
<Button variant="outline" className="w-full sm:flex-1 min-h-9" onClick={onClose}>
|
<Button variant="outline" className="w-full sm:flex-1 min-h-9" onClick={onClose}>
|
||||||
|
|||||||
+10
-22
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
import {
|
import {
|
||||||
RefreshCwIcon, PlusIcon, DownloadIcon, Trash2Icon,
|
RefreshCwIcon, PlusIcon, DownloadIcon, Trash2Icon,
|
||||||
HardDriveIcon, ClockIcon, ServerIcon, CheckCircleIcon,
|
HardDriveIcon, ClockIcon, ServerIcon,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -21,6 +21,7 @@ import { useDataSource } from "@/lib/data-source"
|
|||||||
import { listServers } from "@/shared/api/servers"
|
import { listServers } from "@/shared/api/servers"
|
||||||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||||
import { createBackupsAsync, deleteBackup, getBackupJob, listBackups, type BackupItem } from "@/shared/api/backups"
|
import { createBackupsAsync, deleteBackup, getBackupJob, listBackups, type BackupItem } from "@/shared/api/backups"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
// ─── small UI helpers ─────────────────────────────────────────────────────────
|
// ─── small UI helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -138,11 +139,8 @@ export default function BackupsPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings saved flash
|
|
||||||
const [saved, setSaved] = useState(false)
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
setSaved(true)
|
toast.success("Настройки сохранены")
|
||||||
setTimeout(() => setSaved(false), 2000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual backup sheet
|
// Manual backup sheet
|
||||||
@@ -212,6 +210,7 @@ export default function BackupsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!backupJobId) return
|
if (!backupJobId) return
|
||||||
|
toast.info("Бэкап выполняется в фоне...")
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -223,6 +222,8 @@ export default function BackupsPage() {
|
|||||||
setBackupJobId(null)
|
setBackupJobId(null)
|
||||||
if (job.failures.length > 0) {
|
if (job.failures.length > 0) {
|
||||||
setOpError(`Часть бэкапов не создалась: ${job.failures.map((f) => `${f.serverId}: ${f.error}`).join("; ")}`)
|
setOpError(`Часть бэкапов не создалась: ${job.failures.map((f) => `${f.serverId}: ${f.error}`).join("; ")}`)
|
||||||
|
} else {
|
||||||
|
toast.success("Бэкап успешно завершён")
|
||||||
}
|
}
|
||||||
await loadLive()
|
await loadLive()
|
||||||
} else if (job.status === "failed") {
|
} else if (job.status === "failed") {
|
||||||
@@ -244,6 +245,10 @@ export default function BackupsPage() {
|
|||||||
}
|
}
|
||||||
}, [backendUrl, backupJobId, loadLive])
|
}, [backendUrl, backupJobId, loadLive])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (opError) toast.error(opError)
|
||||||
|
}, [opError])
|
||||||
|
|
||||||
// Delete backup
|
// Delete backup
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
setOpBusy(true)
|
setOpBusy(true)
|
||||||
@@ -367,17 +372,6 @@ export default function BackupsPage() {
|
|||||||
Изменить настройки →
|
Изменить настройки →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{opError && (
|
|
||||||
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
|
|
||||||
{opError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{backupJobId && (
|
|
||||||
<div className="rounded-md border border-blue-500/30 bg-blue-500/10 px-3 py-2 text-sm text-blue-300">
|
|
||||||
Бэкап выполняется в фоне...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex items-center gap-1 border-b border-border">
|
<div className="flex items-center gap-1 border-b border-border">
|
||||||
{([
|
{([
|
||||||
@@ -720,12 +714,6 @@ export default function BackupsPage() {
|
|||||||
<Button onClick={handleSave} className="gap-2">
|
<Button onClick={handleSave} className="gap-2">
|
||||||
Сохранить настройки
|
Сохранить настройки
|
||||||
</Button>
|
</Button>
|
||||||
{saved && (
|
|
||||||
<span className="flex items-center gap-1.5 text-sm text-emerald-500">
|
|
||||||
<CheckCircleIcon className="size-4" />
|
|
||||||
Настройки сохранены
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+15
-12
@@ -5,12 +5,13 @@ import { PageHeader } from "@/components/page-header"
|
|||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
RefreshCwIcon, DownloadIcon, SearchIcon,
|
RefreshCwIcon, DownloadIcon, SearchIcon,
|
||||||
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
|
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
|
||||||
ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
|
ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
|
||||||
XIcon,
|
XIcon, AlertCircleIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
|
||||||
@@ -929,10 +930,10 @@ export default function BgpPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isLive && liveError && (
|
{isLive && liveError && (
|
||||||
<div className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/8 px-3 py-2">
|
<Alert variant="warning" className="py-2">
|
||||||
<span className="size-1.5 rounded-full bg-amber-500 shrink-0" />
|
<AlertCircleIcon />
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Ошибка загрузки: {liveError}</p>
|
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
|
||||||
</div>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{isLive && !loading && liveSessions.length === 0 && !liveError && fetchedAt && (
|
{isLive && !loading && liveSessions.length === 0 && !liveError && fetchedAt && (
|
||||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
@@ -964,13 +965,15 @@ export default function BgpPage() {
|
|||||||
|
|
||||||
{/* alert: not-established sessions */}
|
{/* alert: not-established sessions */}
|
||||||
{notEstab > 0 && (
|
{notEstab > 0 && (
|
||||||
<div className="flex items-center gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/8 px-4 py-2.5">
|
<Alert variant="warning">
|
||||||
<span className="size-2 rounded-full bg-amber-500 shrink-0" />
|
<AlertCircleIcon />
|
||||||
<p className="text-sm text-amber-600 dark:text-amber-400">
|
<AlertTitle>
|
||||||
<span className="font-semibold">{notEstab} сессии</span> не в состоянии Established —
|
<span className="font-semibold">{notEstab} сессии</span> не в состоянии Established
|
||||||
проверьте {sessions.filter(s => s.state !== "Established").map(s => s.peerIp).join(", ")}
|
</AlertTitle>
|
||||||
</p>
|
<AlertDescription>
|
||||||
</div>
|
Проверьте {sessions.filter(s => s.state !== "Established").map(s => s.peerIp).join(", ")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* tab content */}
|
{/* tab content */}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { BandwidthChart } from "@/components/dashboard/bandwidth-chart"
|
|||||||
import {
|
import {
|
||||||
servers as mockServers,
|
servers as mockServers,
|
||||||
pingProbes,
|
pingProbes,
|
||||||
systemEvents,
|
|
||||||
dashLatency,
|
dashLatency,
|
||||||
traffic,
|
traffic,
|
||||||
serverFilterRulesets,
|
serverFilterRulesets,
|
||||||
@@ -26,6 +25,8 @@ import { Button, buttonVariants } from "@/components/ui/button"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency"
|
import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency"
|
||||||
|
import { listEvents } from "@/shared/api/events"
|
||||||
|
import type { EventItem } from "@/packages/contracts/src/events"
|
||||||
|
|
||||||
function makeApiFetch(backendUrl: string) {
|
function makeApiFetch(backendUrl: string) {
|
||||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
@@ -71,6 +72,19 @@ function fmtIntRu(n: number): string {
|
|||||||
return n.toLocaleString("ru-RU")
|
return n.toLocaleString("ru-RU")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatEventAge(iso: string): string {
|
||||||
|
const ts = Date.parse(iso)
|
||||||
|
if (!Number.isFinite(ts)) return "—"
|
||||||
|
const diffMs = Math.max(0, Date.now() - ts)
|
||||||
|
const minutes = Math.floor(diffMs / 60_000)
|
||||||
|
if (minutes < 1) return "сейчас"
|
||||||
|
if (minutes < 60) return `${minutes}м`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours}ч`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
return `${days}д`
|
||||||
|
}
|
||||||
|
|
||||||
interface LiveKpiSnapshot {
|
interface LiveKpiSnapshot {
|
||||||
filters: { ruleTotal: number; serversWithRules: number } | null
|
filters: { ruleTotal: number; serversWithRules: number } | null
|
||||||
bgp: { prefixSum: number; establishedCount: number } | null
|
bgp: { prefixSum: number; establishedCount: number } | null
|
||||||
@@ -198,6 +212,9 @@ export default function DashboardPage() {
|
|||||||
const [liveKpi, setLiveKpi] = useState<LiveKpiSnapshot | null>(null)
|
const [liveKpi, setLiveKpi] = useState<LiveKpiSnapshot | null>(null)
|
||||||
const [probesLoading, setProbesLoading] = useState(false)
|
const [probesLoading, setProbesLoading] = useState(false)
|
||||||
const [probesError, setProbesError] = useState<string | null>(null)
|
const [probesError, setProbesError] = useState<string | null>(null)
|
||||||
|
const [recentEvents, setRecentEvents] = useState<EventItem[]>([])
|
||||||
|
const [eventsLoading, setEventsLoading] = useState(false)
|
||||||
|
const [eventsError, setEventsError] = useState<string | null>(null)
|
||||||
|
|
||||||
const probeServerCatalog = useMemo(() => {
|
const probeServerCatalog = useMemo(() => {
|
||||||
if (!isLive) return mockServers
|
if (!isLive) return mockServers
|
||||||
@@ -259,6 +276,25 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
}, [apiFetch, isLive])
|
}, [apiFetch, isLive])
|
||||||
|
|
||||||
|
const fetchRecentEvents = useCallback(async (silent: boolean) => {
|
||||||
|
if (!isLive) {
|
||||||
|
setRecentEvents([])
|
||||||
|
setEventsError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!silent) setEventsLoading(true)
|
||||||
|
try {
|
||||||
|
const rows = await listEvents(backendUrl, { limit: 8 })
|
||||||
|
setRecentEvents(rows)
|
||||||
|
setEventsError(null)
|
||||||
|
} catch (error) {
|
||||||
|
setRecentEvents([])
|
||||||
|
setEventsError(error instanceof Error ? error.message : "Не удалось загрузить события")
|
||||||
|
} finally {
|
||||||
|
if (!silent) setEventsLoading(false)
|
||||||
|
}
|
||||||
|
}, [backendUrl, isLive])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -277,6 +313,19 @@ export default function DashboardPage() {
|
|||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [isLive, fetchProbes])
|
}, [isLive, fetchProbes])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void fetchRecentEvents(false)
|
||||||
|
})
|
||||||
|
}, [fetchRecentEvents])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => {
|
||||||
|
queueMicrotask(() => { void fetchRecentEvents(true) })
|
||||||
|
}, 20_000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [fetchRecentEvents])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) return
|
if (!isLive) return
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
@@ -628,24 +677,38 @@ export default function DashboardPage() {
|
|||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-base">Последние события</CardTitle>
|
<CardTitle className="text-base">Последние события</CardTitle>
|
||||||
<Button variant="ghost" size="sm" className="text-xs h-7">Все →</Button>
|
<Link
|
||||||
|
href="/alerts"
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "text-xs h-7")}
|
||||||
|
>
|
||||||
|
Все →
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Система и BGP-активность</p>
|
<p className="text-sm text-muted-foreground">Система и BGP-активность</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0 px-0">
|
<CardContent className="pt-0 px-0">
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{systemEvents.map((e) => (
|
{eventsLoading && recentEvents.length === 0 && (
|
||||||
|
<div className="px-5 py-6 text-sm text-muted-foreground">Загрузка событий...</div>
|
||||||
|
)}
|
||||||
|
{eventsError && recentEvents.length === 0 && (
|
||||||
|
<div className="px-5 py-6 text-sm text-destructive">{eventsError}</div>
|
||||||
|
)}
|
||||||
|
{!eventsLoading && !eventsError && recentEvents.length === 0 && (
|
||||||
|
<div className="px-5 py-6 text-sm text-muted-foreground">Событий пока нет.</div>
|
||||||
|
)}
|
||||||
|
{recentEvents.map((e) => (
|
||||||
<div key={e.id} className="grid grid-cols-[20px_1fr_auto] gap-3 px-5 py-3 items-start">
|
<div key={e.id} className="grid grid-cols-[20px_1fr_auto] gap-3 px-5 py-3 items-start">
|
||||||
<div className="mt-0.5">
|
<div className="mt-0.5">
|
||||||
{e.sev === "destructive" && <AlertCircleIcon className="size-4 text-destructive" />}
|
{e.level === "critical" && <AlertCircleIcon className="size-4 text-destructive" />}
|
||||||
{e.sev === "warning" && <AlertTriangleIcon className="size-4 text-[var(--status-degraded)]" />}
|
{e.level === "warning" && <AlertTriangleIcon className="size-4 text-[var(--status-degraded)]" />}
|
||||||
{e.sev === "info" && <InfoIcon className="size-4 text-[var(--chart-1)]" />}
|
{e.level === "info" && <InfoIcon className="size-4 text-[var(--chart-1)]" />}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[13px] font-medium leading-tight">{e.title}</p>
|
<p className="text-[13px] font-medium leading-tight">{e.title}</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">{e.message}</p>
|
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">{e.message}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] font-mono text-muted-foreground">{e.when}</span>
|
<span className="text-[11px] font-mono text-muted-foreground">{formatEventAge(e.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { PageHeader } from "@/components/page-header"
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@@ -101,7 +102,10 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор трафика ещё выполнялся.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор трафика ещё выполнялся.</p>
|
||||||
) : null}
|
) : null}
|
||||||
{t.fatalError ? (
|
{t.fatalError ? (
|
||||||
<p className="text-xs text-destructive">Критическая ошибка: {t.fatalError}</p>
|
<Alert variant="destructive" className="py-2">
|
||||||
|
<AlertCircleIcon />
|
||||||
|
<AlertDescription className="text-xs">Критическая ошибка: {t.fatalError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Сэмплы на момент <span className="font-mono tabular-nums">{new Date(t.sampledAt).toLocaleString("ru-RU")}</span>
|
Сэмплы на момент <span className="font-mono tabular-nums">{new Date(t.sampledAt).toLocaleString("ru-RU")}</span>
|
||||||
@@ -154,7 +158,12 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
{u.skipped ? (
|
{u.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ресурсов уже выполняется.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ресурсов уже выполняется.</p>
|
||||||
) : null}
|
) : null}
|
||||||
{u.fatalError ? <p className="text-xs text-destructive">Критическая ошибка: {u.fatalError}</p> : null}
|
{u.fatalError ? (
|
||||||
|
<Alert variant="destructive" className="py-2">
|
||||||
|
<AlertCircleIcon />
|
||||||
|
<AlertDescription className="text-xs">Критическая ошибка: {u.fatalError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Сэмплы на <span className="font-mono tabular-nums">{new Date(u.sampledAt).toLocaleString("ru-RU")}</span>
|
Сэмплы на <span className="font-mono tabular-nums">{new Date(u.sampledAt).toLocaleString("ru-RU")}</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -221,7 +230,12 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
{s.skipped ? (
|
{s.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка API ещё выполнялась.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка API ещё выполнялась.</p>
|
||||||
) : null}
|
) : null}
|
||||||
{s.fatalError ? <p className="text-xs text-destructive">Критическая ошибка: {s.fatalError}</p> : null}
|
{s.fatalError ? (
|
||||||
|
<Alert variant="destructive" className="py-2">
|
||||||
|
<AlertCircleIcon />
|
||||||
|
<AlertDescription className="text-xs">Критическая ошибка: {s.fatalError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
GET <span className="font-mono">/system/identity</span> на{" "}
|
GET <span className="font-mono">/system/identity</span> на{" "}
|
||||||
<span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span>
|
<span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span>
|
||||||
@@ -272,7 +286,12 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
{p.skipped ? (
|
{p.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ping уже выполняется.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ping уже выполняется.</p>
|
||||||
) : null}
|
) : null}
|
||||||
{p.fatalError ? <p className="text-xs text-destructive">Критическая ошибка: {p.fatalError}</p> : null}
|
{p.fatalError ? (
|
||||||
|
<Alert variant="destructive" className="py-2">
|
||||||
|
<AlertCircleIcon />
|
||||||
|
<AlertDescription className="text-xs">Критическая ошибка: {p.fatalError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
{p.skippedByInterval != null && p.skippedByInterval > 0 ? (
|
{p.skippedByInterval != null && p.skippedByInterval > 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">Пропущено по интервалу проб: {p.skippedByInterval}</p>
|
<p className="text-xs text-muted-foreground">Пропущено по интервалу проб: {p.skippedByInterval}</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -384,7 +403,12 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
{g.skipped ? (
|
{g.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p>
|
||||||
) : null}
|
) : null}
|
||||||
{g.fatalError ? <p className="text-xs text-destructive">Критическая ошибка: {g.fatalError}</p> : null}
|
{g.fatalError ? (
|
||||||
|
<Alert variant="destructive" className="py-2">
|
||||||
|
<AlertCircleIcon />
|
||||||
|
<AlertDescription className="text-xs">Критическая ошибка: {g.fatalError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Запись в SQLite на{" "}
|
Запись в SQLite на{" "}
|
||||||
<span className="font-mono tabular-nums">{new Date(g.sampledAt).toLocaleString("ru-RU")}</span>
|
<span className="font-mono tabular-nums">{new Date(g.sampledAt).toLocaleString("ru-RU")}</span>
|
||||||
@@ -470,13 +494,16 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
{a.errors?.length ? (
|
{a.errors?.length ? (
|
||||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive space-y-1">
|
<Alert variant="destructive" className="py-2">
|
||||||
{a.errors.map((e, i) => (
|
<AlertCircleIcon />
|
||||||
<p key={i} className="break-words">
|
<AlertDescription className="space-y-1 text-xs">
|
||||||
{e}
|
{a.errors.map((e, i) => (
|
||||||
</p>
|
<p key={i} className="break-words">
|
||||||
))}
|
{e}
|
||||||
</div>
|
</p>
|
||||||
|
))}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
{a.ruleDiag && a.ruleDiag.length > 0 ? (
|
{a.ruleDiag && a.ruleDiag.length > 0 ? (
|
||||||
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 space-y-2">
|
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 space-y-2">
|
||||||
@@ -824,12 +851,11 @@ export default function DataCollectionPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isLive && collectorError && (
|
{isLive && collectorError && (
|
||||||
<Card className="border-destructive/50">
|
<Alert variant="destructive">
|
||||||
<CardContent className="flex items-start gap-2 pt-4 pb-4 px-4">
|
<AlertCircleIcon />
|
||||||
<AlertCircleIcon className="size-4 text-destructive shrink-0 mt-0.5" />
|
<AlertTitle>Ошибка загрузки статуса коллекторов</AlertTitle>
|
||||||
<p className="text-sm text-destructive">{collectorError}</p>
|
<AlertDescription>{collectorError}</AlertDescription>
|
||||||
</CardContent>
|
</Alert>
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLive && (
|
{isLive && (
|
||||||
|
|||||||
+81
-32
@@ -29,6 +29,7 @@ import {
|
|||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
// ── helpers ────────────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -201,39 +202,71 @@ function generateCombinedRouterOSConfig(
|
|||||||
lines.push(`# ${server.name} · ${server.type === "jump-host" ? "JH" : "EN"} · ${server.site} · ${server.host}`)
|
lines.push(`# ${server.name} · ${server.type === "jump-host" ? "JH" : "EN"} · ${server.site} · ${server.host}`)
|
||||||
|
|
||||||
const recList = recRoutesByServer[rs.serverId] ?? []
|
const recList = recRoutesByServer[rs.serverId] ?? []
|
||||||
const branches = rs.rules.map((rule, i) => {
|
// Группировка по эффекту: communities с одинаковым `set gw` (+ интерфейс) объединяются
|
||||||
|
// в один if-блок через `||` — компактнее и привычнее для bgp-in.
|
||||||
|
// RouterOS не поддерживает `else if`, поэтому между группами — независимые `if`-блоки;
|
||||||
|
// `accept;` в первом совпавшем блоке завершает обработку, дальнейшие if не выполняются.
|
||||||
|
type Group = {
|
||||||
|
isBlackhole: boolean
|
||||||
|
gateway: string
|
||||||
|
outIface: string
|
||||||
|
items: Array<{ community: string; desc: string }>
|
||||||
|
}
|
||||||
|
const groups: Group[] = []
|
||||||
|
const indexByKey = new Map<string, number>()
|
||||||
|
|
||||||
|
for (const rule of rs.rules) {
|
||||||
const isBlackhole = rule.action === "blackhole"
|
const isBlackhole = rule.action === "blackhole"
|
||||||
const label = rule.communityName ?? rule.community
|
const label = rule.communityName ?? rule.community
|
||||||
const desc = rule.description ? `${label} — ${rule.description}` : label
|
const desc = rule.description ? `${label} — ${rule.description}` : label
|
||||||
const kw = i === 0 ? "if" : "} else if"
|
|
||||||
if (isBlackhole) {
|
let gw = ""
|
||||||
|
let iface = ""
|
||||||
|
if (!isBlackhole) {
|
||||||
|
if (isRecursiveGatewayRef(rule.gatewayTunnelId)) {
|
||||||
|
const rid = rule.gatewayTunnelId.slice(4)
|
||||||
|
const rr = recList.find(r => r.id === rid)
|
||||||
|
gw = rr ? gatewayFromRecursiveDst(rr.dstAddress) : rule.gateway
|
||||||
|
} else {
|
||||||
|
const tunnel = tunnelsList.find(t => t.id === rule.gatewayTunnelId)
|
||||||
|
gw = rule.gateway
|
||||||
|
iface = tunnel ? tunnel.name : "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const key = isBlackhole ? "bh" : `rt:${gw}:${iface}`
|
||||||
|
let idx = indexByKey.get(key)
|
||||||
|
if (idx === undefined) {
|
||||||
|
idx = groups.length
|
||||||
|
indexByKey.set(key, idx)
|
||||||
|
groups.push({ isBlackhole, gateway: gw, outIface: iface, items: [] })
|
||||||
|
}
|
||||||
|
groups[idx].items.push({ community: rule.community, desc })
|
||||||
|
}
|
||||||
|
|
||||||
|
const branches = groups.map(g => {
|
||||||
|
const cond = g.items
|
||||||
|
.map(it => `bgp-communities includes ${it.community}`)
|
||||||
|
.join(" || ")
|
||||||
|
const commentLines = g.items.map(it => ` # ${it.community}: ${it.desc}`)
|
||||||
|
|
||||||
|
if (g.isBlackhole) {
|
||||||
return [
|
return [
|
||||||
` ${kw} (bgp-communities.has(\\"${rule.community}\\")) {`,
|
` if (${cond}) {`,
|
||||||
` # ${desc}`,
|
...commentLines,
|
||||||
` set type blackhole;`,
|
` set type blackhole;`,
|
||||||
` accept;`,
|
` accept;`,
|
||||||
|
` }`,
|
||||||
].join("\n")
|
].join("\n")
|
||||||
}
|
}
|
||||||
if (isRecursiveGatewayRef(rule.gatewayTunnelId)) {
|
const out = [
|
||||||
const rid = rule.gatewayTunnelId.slice(4)
|
` if (${cond}) {`,
|
||||||
const rr = recList.find(r => r.id === rid)
|
...commentLines,
|
||||||
const gw = rr ? gatewayFromRecursiveDst(rr.dstAddress) : rule.gateway
|
` set gw ${g.gateway};`,
|
||||||
return [
|
]
|
||||||
` ${kw} (bgp-communities.has(\\"${rule.community}\\")) {`,
|
if (g.outIface) out.push(` set out-interface ${g.outIface};`)
|
||||||
` # ${desc}`,
|
out.push(` accept;`)
|
||||||
` set gateway ${gw};`,
|
out.push(` }`)
|
||||||
` accept;`,
|
return out.join("\n")
|
||||||
].join("\n")
|
|
||||||
}
|
|
||||||
const tunnel = tunnelsList.find(t => t.id === rule.gatewayTunnelId)
|
|
||||||
const iface = tunnel ? tunnel.name : "unknown"
|
|
||||||
return [
|
|
||||||
` ${kw} (bgp-communities.has(\\"${rule.community}\\")) {`,
|
|
||||||
` # ${desc}`,
|
|
||||||
` set gateway ${rule.gateway};`,
|
|
||||||
` set out-interface ${iface};`,
|
|
||||||
` accept;`,
|
|
||||||
].join("\n")
|
|
||||||
}).join("\n")
|
}).join("\n")
|
||||||
|
|
||||||
lines.push(`/routing filter rule add \\`)
|
lines.push(`/routing filter rule add \\`)
|
||||||
@@ -241,7 +274,7 @@ function generateCombinedRouterOSConfig(
|
|||||||
lines.push(` comment="RouterLists: ${server.name}" \\`)
|
lines.push(` comment="RouterLists: ${server.name}" \\`)
|
||||||
lines.push(` rule="`)
|
lines.push(` rule="`)
|
||||||
lines.push(branches)
|
lines.push(branches)
|
||||||
lines.push(` }"`)
|
lines.push(` "`)
|
||||||
|
|
||||||
if (si < withRules.length - 1) lines.push(``)
|
if (si < withRules.length - 1) lines.push(``)
|
||||||
})
|
})
|
||||||
@@ -876,9 +909,9 @@ function RuleSheet({
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-1 rounded bg-[#0d1117] px-3 py-2 font-mono text-[10px] leading-relaxed text-[#8b949e] overflow-x-auto">
|
<div className="mt-1 rounded bg-[#0d1117] px-3 py-2 font-mono text-[10px] leading-relaxed text-[#8b949e] overflow-x-auto">
|
||||||
<span className="text-[#ff7b72]">if</span>
|
<span className="text-[#ff7b72]">if</span>
|
||||||
{" (bgp-communities.has(\""}
|
{" (bgp-communities includes "}
|
||||||
<span className="text-[#79c0ff]">{form.community || "AS:NNN"}</span>
|
<span className="text-[#79c0ff]">{form.community || "AS:NNN"}</span>
|
||||||
{"\")) {\n "}
|
{") {\n "}
|
||||||
<span className="text-[#ff7b72]">set type blackhole</span>
|
<span className="text-[#ff7b72]">set type blackhole</span>
|
||||||
{";\n accept;\n}"}
|
{";\n accept;\n}"}
|
||||||
</div>
|
</div>
|
||||||
@@ -999,7 +1032,7 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
|
|||||||
trimmed.startsWith("set type blackhole")
|
trimmed.startsWith("set type blackhole")
|
||||||
? "text-[#ff7b72] font-semibold" :
|
? "text-[#ff7b72] font-semibold" :
|
||||||
// rule body: set actions
|
// rule body: set actions
|
||||||
trimmed.startsWith("set gateway") || trimmed.startsWith("set out-interface")
|
trimmed.startsWith("set gw") || trimmed.startsWith("set gateway") || trimmed.startsWith("set out-interface")
|
||||||
? "text-[#a5d6ff]" :
|
? "text-[#a5d6ff]" :
|
||||||
// rule body: accept / rule close
|
// rule body: accept / rule close
|
||||||
trimmed.startsWith("accept") || trimmed === `}"` || trimmed.startsWith(`rule="`)
|
trimmed.startsWith("accept") || trimmed === `}"` || trimmed.startsWith(`rule="`)
|
||||||
@@ -1730,15 +1763,31 @@ export default function FiltersPage() {
|
|||||||
}, [isLive, syncBusy, apiFetch, allServers, selectedServerId, ensureGreTunnels, ensureRecursiveRoutes, fetchRouterCompare])
|
}, [isLive, syncBusy, apiFetch, allServers, selectedServerId, ensureGreTunnels, ensureRecursiveRoutes, fetchRouterCompare])
|
||||||
|
|
||||||
const syncToRouter = useCallback(async () => {
|
const syncToRouter = useCallback(async () => {
|
||||||
if (!isLive || syncBusy) return
|
if (!isLive || syncBusy || !selectedServerId) return
|
||||||
setSyncBusy("to")
|
setSyncBusy("to")
|
||||||
try {
|
try {
|
||||||
await apiFetch<{ ok: boolean }>("/api/filters/sync/to-router", { method: "POST" })
|
const res = await apiFetch<{
|
||||||
|
ok: boolean
|
||||||
|
updatedServers: number
|
||||||
|
pushedRules: number
|
||||||
|
errors?: Array<{ serverId: number; error: string }>
|
||||||
|
}>("/api/filters/sync/to-router", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ serverId: selectedServerId }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success(`Загружено правил на роутер: ${res.pushedRules}`)
|
||||||
|
} else {
|
||||||
|
const detail = res.errors?.[0]?.error ?? "неизвестная ошибка"
|
||||||
|
toast.error("Не удалось загрузить правила на роутер", { description: detail })
|
||||||
|
}
|
||||||
await fetchRouterCompare()
|
await fetchRouterCompare()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Не удалось загрузить правила на роутер", { description: String(err) })
|
||||||
} finally {
|
} finally {
|
||||||
setSyncBusy(null)
|
setSyncBusy(null)
|
||||||
}
|
}
|
||||||
}, [isLive, syncBusy, apiFetch, fetchRouterCompare])
|
}, [isLive, syncBusy, selectedServerId, apiFetch, fetchRouterCompare])
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setSheetInitial(emptyForm()); setSheetMode("create"); setEditingId(null); setSheetOpen(true)
|
setSheetInitial(emptyForm()); setSheetMode("create"); setEditingId(null); setSheetOpen(true)
|
||||||
|
|||||||
+10
-15
@@ -7,6 +7,7 @@ import type { GrePool, GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhG
|
|||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toast } from "sonner"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -25,7 +26,6 @@ import {
|
|||||||
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
|
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
|
||||||
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
|
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
|
||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
AlertCircleIcon,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
// ─── label maps ─────────────────────────────────────────────────────────────
|
// ─── label maps ─────────────────────────────────────────────────────────────
|
||||||
@@ -281,7 +281,6 @@ export default function GrePage() {
|
|||||||
const [dataLoading, setDataLoading] = useState(false)
|
const [dataLoading, setDataLoading] = useState(false)
|
||||||
const [dataError, setDataError] = useState<string | null>(null)
|
const [dataError, setDataError] = useState<string | null>(null)
|
||||||
const [syncJhBusy, setSyncJhBusy] = useState(false)
|
const [syncJhBusy, setSyncJhBusy] = useState(false)
|
||||||
const [syncJhMessage, setSyncJhMessage] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
|
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
|
||||||
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
|
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
|
||||||
@@ -302,7 +301,6 @@ export default function GrePage() {
|
|||||||
if (!isLive) return
|
if (!isLive) return
|
||||||
setDataLoading(true)
|
setDataLoading(true)
|
||||||
setDataError(null)
|
setDataError(null)
|
||||||
setSyncJhMessage(null)
|
|
||||||
try {
|
try {
|
||||||
const [backendServers, greRes] = await Promise.all([
|
const [backendServers, greRes] = await Promise.all([
|
||||||
apiFetch<BackendServer[]>("/api/servers"),
|
apiFetch<BackendServer[]>("/api/servers"),
|
||||||
@@ -353,11 +351,10 @@ export default function GrePage() {
|
|||||||
if (!isLive || syncJhBusy) return
|
if (!isLive || syncJhBusy) return
|
||||||
const jh = displayServers.filter((s) => s.type === "jump-host" && s.enabled)
|
const jh = displayServers.filter((s) => s.type === "jump-host" && s.enabled)
|
||||||
if (jh.length === 0) {
|
if (jh.length === 0) {
|
||||||
setSyncJhMessage("Нет включённых Jump Host в списке серверов")
|
toast.info("Нет включённых Jump Host в списке серверов")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSyncJhBusy(true)
|
setSyncJhBusy(true)
|
||||||
setSyncJhMessage(null)
|
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
try {
|
try {
|
||||||
for (const s of jh) {
|
for (const s of jh) {
|
||||||
@@ -373,17 +370,21 @@ export default function GrePage() {
|
|||||||
const fresh = await apiFetch<GreTunnelsApiResponse>("/api/filters/gre-tunnels")
|
const fresh = await apiFetch<GreTunnelsApiResponse>("/api/filters/gre-tunnels")
|
||||||
setLiveTunnels(fresh.tunnels)
|
setLiveTunnels(fresh.tunnels)
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
setSyncJhMessage(`Синхронизировано JH: ${jh.length - errors.length}/${jh.length}. Ошибки: ${errors.join("; ")}`)
|
toast.warning(`Синхронизировано JH: ${jh.length - errors.length}/${jh.length}. Ошибки: ${errors.join("; ")}`)
|
||||||
} else {
|
} else {
|
||||||
setSyncJhMessage(`Правила с ${jh.length} JH записаны в БД, список GRE обновлён.`)
|
toast.success(`Правила с ${jh.length} JH записаны в БД, список GRE обновлён.`)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSyncJhMessage(e instanceof Error ? e.message : "Ошибка после синхронизации")
|
toast.error(e instanceof Error ? e.message : "Ошибка после синхронизации")
|
||||||
} finally {
|
} finally {
|
||||||
setSyncJhBusy(false)
|
setSyncJhBusy(false)
|
||||||
}
|
}
|
||||||
}, [isLive, syncJhBusy, apiFetch, displayServers])
|
}, [isLive, syncJhBusy, apiFetch, displayServers])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dataError) toast.error(dataError)
|
||||||
|
}, [dataError])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return displayTunnels.filter((t) => {
|
return displayTunnels.filter((t) => {
|
||||||
if (tabFilter === "up" && t.status !== "up") return false
|
if (tabFilter === "up" && t.status !== "up") return false
|
||||||
@@ -413,6 +414,7 @@ export default function GrePage() {
|
|||||||
function handleCopy(code: string) {
|
function handleCopy(code: string) {
|
||||||
navigator.clipboard.writeText(code).then(() => {
|
navigator.clipboard.writeText(code).then(() => {
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
|
toast.success("Команды скопированы")
|
||||||
setTimeout(() => setCopied(false), 2000)
|
setTimeout(() => setCopied(false), 2000)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -453,13 +455,6 @@ export default function GrePage() {
|
|||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
{(dataError || syncJhMessage) && (
|
|
||||||
<div className={`flex items-start gap-3 rounded-lg border px-4 py-3 text-sm ${dataError ? "bg-destructive/5 border-destructive/30 text-destructive" : "bg-muted/40 border-border text-muted-foreground"}`}>
|
|
||||||
<AlertCircleIcon className="size-5 shrink-0 mt-0.5" />
|
|
||||||
<div>{dataError ?? syncJhMessage}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Legacy banner */}
|
{/* Legacy banner */}
|
||||||
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm">
|
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm">
|
||||||
<ShieldCheckIcon className="size-5 text-amber-500 shrink-0 mt-0.5" />
|
<ShieldCheckIcon className="size-5 text-amber-500 shrink-0 mt-0.5" />
|
||||||
|
|||||||
@@ -56,12 +56,6 @@ import { Flag } from "@/components/flag"
|
|||||||
|
|
||||||
// ─── Resource metrics (для мини-блока справа; числа детерминированы по id узла) ─
|
// ─── Resource metrics (для мини-блока справа; числа детерминированы по id узла) ─
|
||||||
|
|
||||||
const BOARD_MAP: Record<ServerType, string> = {
|
|
||||||
"jump-host": "RB5009UG+S+IN",
|
|
||||||
"exit-node": "RB4011iGS+RM",
|
|
||||||
"home-router": "hAP ax²",
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Backend → frontend (как /servers) ───────────────────────────────────────
|
// ─── Backend → frontend (как /servers) ───────────────────────────────────────
|
||||||
|
|
||||||
interface BackendServerRow {
|
interface BackendServerRow {
|
||||||
@@ -707,11 +701,12 @@ function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters,
|
|||||||
stroke={TUNNEL_STYLE[e.tunnel.status].stroke} strokeWidth="5" opacity="0.25" />
|
stroke={TUNNEL_STYLE[e.tunnel.status].stroke} strokeWidth="5" opacity="0.25" />
|
||||||
))}
|
))}
|
||||||
{/* WAN edges */}
|
{/* WAN edges */}
|
||||||
{wanJhEdges.map((edge, i) => {
|
{wanJhEdges.map((edge) => {
|
||||||
const sat = satPos[edge.homeId]?.[edge.wanIdx]
|
const sat = satPos[edge.homeId]?.[edge.wanIdx]
|
||||||
const jh = nodes.find(n => n.id === edge.jhId)
|
const jh = nodes.find(n => n.id === edge.jhId)
|
||||||
if (!sat || !jh) return null
|
if (!sat || !jh) return null
|
||||||
return <line key={i} x1={sat.x} y1={sat.y} x2={jh.x} y2={jh.y}
|
const edgeKey = `${edge.homeId}-${edge.wanIdx}-${edge.jhId}`
|
||||||
|
return <line key={edgeKey} x1={sat.x} y1={sat.y} x2={jh.x} y2={jh.y}
|
||||||
stroke={WAN_COLORS[edge.wanIdx]} strokeWidth="3" opacity="0.25" />
|
stroke={WAN_COLORS[edge.wanIdx]} strokeWidth="3" opacity="0.25" />
|
||||||
})}
|
})}
|
||||||
{/* nodes */}
|
{/* nodes */}
|
||||||
@@ -1333,11 +1328,52 @@ export default function NetworkMapPage() {
|
|||||||
|
|
||||||
const connectedTunnels = selected
|
const connectedTunnels = selected
|
||||||
? mapGreTunnels.filter((t) => {
|
? mapGreTunnels.filter((t) => {
|
||||||
|
if (t.serverId === selected.id) return true
|
||||||
const peer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
|
const peer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
|
||||||
if (!peer) return false
|
if (peer?.id === selected.id) return true
|
||||||
return t.serverId === selected.id || peer.id === selected.id
|
const selectedHost = normalizeGreEndpointAddr(selected.host)
|
||||||
|
const selectedWanIps = (selected.wanUplinks ?? [])
|
||||||
|
.map((w) => normalizeGreEndpointAddr(w.ip))
|
||||||
|
.filter(Boolean)
|
||||||
|
const remote = normalizeGreEndpointAddr(t.remoteAddress)
|
||||||
|
const local = normalizeGreEndpointAddr(t.localAddress)
|
||||||
|
return (
|
||||||
|
(selectedHost.length > 0 && (remote === selectedHost || local === selectedHost)) ||
|
||||||
|
selectedWanIps.includes(remote) ||
|
||||||
|
selectedWanIps.includes(local)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
|
const grePrimaryByGroupKey = useMemo(() => {
|
||||||
|
if (!selected) return new Map<string, string>()
|
||||||
|
const group = new Map<string, GreTunnel[]>()
|
||||||
|
const score = (t: GreTunnel): number => {
|
||||||
|
const statusScore = t.status === "up" ? 3 : t.status === "degraded" ? 2 : 1
|
||||||
|
const enabledScore = t.enabled ? 10 : 0
|
||||||
|
return enabledScore + statusScore
|
||||||
|
}
|
||||||
|
for (const t of connectedTunnels) {
|
||||||
|
const peer =
|
||||||
|
t.serverId === selected.id
|
||||||
|
? findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
|
||||||
|
: mapServers.find((s) => s.id === t.serverId)
|
||||||
|
const groupKey = peer?.id ?? (normalizeGreEndpointAddr(t.remoteAddress) || t.remoteAddress)
|
||||||
|
const arr = group.get(groupKey) ?? []
|
||||||
|
arr.push(t)
|
||||||
|
group.set(groupKey, arr)
|
||||||
|
}
|
||||||
|
const primaryByKey = new Map<string, string>()
|
||||||
|
for (const [groupKey, tunnels] of group.entries()) {
|
||||||
|
const sorted = [...tunnels].sort((a, b) => {
|
||||||
|
const d = score(b) - score(a)
|
||||||
|
if (d !== 0) return d
|
||||||
|
return a.id.localeCompare(b.id)
|
||||||
|
})
|
||||||
|
const first = sorted[0]
|
||||||
|
if (first) primaryByKey.set(groupKey, first.id)
|
||||||
|
}
|
||||||
|
return primaryByKey
|
||||||
|
}, [connectedTunnels, selected, mapServers, greResolvedMap])
|
||||||
|
|
||||||
const hoveredNode = hoveredId ? nodes.find(n => n.id === hoveredId) : null
|
const hoveredNode = hoveredId ? nodes.find(n => n.id === hoveredId) : null
|
||||||
|
|
||||||
@@ -1619,16 +1655,17 @@ export default function NetworkMapPage() {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{/* ── WAN→JH edges ── */}
|
{/* ── WAN→JH edges ── */}
|
||||||
{visibleWanJhEdges.map((edge, i) => {
|
{visibleWanJhEdges.map((edge) => {
|
||||||
const jh = nodeById[edge.jhId]
|
const jh = nodeById[edge.jhId]
|
||||||
const satPos = effectiveSatPos[edge.homeId]?.[edge.wanIdx]
|
const satPos = effectiveSatPos[edge.homeId]?.[edge.wanIdx]
|
||||||
if (!jh || !satPos) return null
|
if (!jh || !satPos) return null
|
||||||
|
const edgeKey = `${edge.homeId}-${edge.wanIdx}-${edge.jhId}`
|
||||||
const color = WAN_COLORS[edge.wanIdx] ?? "#888"
|
const color = WAN_COLORS[edge.wanIdx] ?? "#888"
|
||||||
const vis = filter === "all" || filter === "home-router" || filter === "jump-host" || filter === "online"
|
const vis = filter === "all" || filter === "home-router" || filter === "jump-host" || filter === "online"
|
||||||
const { mx, my } = edgeBadgePosition(satPos.x, satPos.y, jh.x, jh.y, 0.62, -17)
|
const { mx, my } = edgeBadgePosition(satPos.x, satPos.y, jh.x, jh.y, 0.62, -17)
|
||||||
const isHL = selected?.id === edge.homeId && (selWanIdx === null || selWanIdx === edge.wanIdx)
|
const isHL = selected?.id === edge.homeId && (selWanIdx === null || selWanIdx === edge.wanIdx)
|
||||||
return (
|
return (
|
||||||
<g key={`wan-jh-${i}`} opacity={vis ? (isHL ? 1 : 0.45) : 0.05}
|
<g key={edgeKey} opacity={vis ? (isHL ? 1 : 0.45) : 0.05}
|
||||||
style={{ transition: "opacity 0.3s" }}>
|
style={{ transition: "opacity 0.3s" }}>
|
||||||
<line
|
<line
|
||||||
x1={satPos.x} y1={satPos.y} x2={jh.x} y2={jh.y}
|
x1={satPos.x} y1={satPos.y} x2={jh.x} y2={jh.y}
|
||||||
@@ -1640,7 +1677,7 @@ export default function NetworkMapPage() {
|
|||||||
/>
|
/>
|
||||||
{showAnimDots && edge.active && (
|
{showAnimDots && edge.active && (
|
||||||
<circle r="3.5" fill={color} opacity="0.85">
|
<circle r="3.5" fill={color} opacity="0.85">
|
||||||
<animateMotion dur={`${1.8 + i * 0.3}s`} repeatCount="indefinite"
|
<animateMotion dur={`${1.8 + (edge.wanIdx % 5) * 0.3}s`} repeatCount="indefinite"
|
||||||
path={`M ${satPos.x} ${satPos.y} L ${jh.x} ${jh.y}`} />
|
path={`M ${satPos.x} ${satPos.y} L ${jh.x} ${jh.y}`} />
|
||||||
</circle>
|
</circle>
|
||||||
)}
|
)}
|
||||||
@@ -2143,10 +2180,11 @@ export default function NetworkMapPage() {
|
|||||||
<p className="text-[9px] uppercase text-muted-foreground tracking-wider mb-1.5">
|
<p className="text-[9px] uppercase text-muted-foreground tracking-wider mb-1.5">
|
||||||
Подключения к JH
|
Подключения к JH
|
||||||
</p>
|
</p>
|
||||||
{myEdges.map((e, ei) => {
|
{myEdges.map((e) => {
|
||||||
const jh = mapServers.find(s => s.id === e.jhId)
|
const jh = mapServers.find(s => s.id === e.jhId)
|
||||||
|
const edgeKey = `${e.homeId}-${e.wanIdx}-${e.jhId}`
|
||||||
return (
|
return (
|
||||||
<div key={ei} className="flex items-center justify-between py-0.5">
|
<div key={edgeKey} className="flex items-center justify-between py-0.5">
|
||||||
<span className="text-[10px] font-mono text-muted-foreground truncate">
|
<span className="text-[10px] font-mono text-muted-foreground truncate">
|
||||||
→ {jh?.name ?? e.jhId}
|
→ {jh?.name ?? e.jhId}
|
||||||
</span>
|
</span>
|
||||||
@@ -2186,6 +2224,8 @@ export default function NetworkMapPage() {
|
|||||||
? `${fromServer.id}|${toServer.id}|${t.id}|${t.localAddress}|${t.remoteAddress}`
|
? `${fromServer.id}|${toServer.id}|${t.id}|${t.localAddress}|${t.remoteAddress}`
|
||||||
: `${t.serverId}|${t.id}|${t.localAddress}|${t.remoteAddress}`
|
: `${t.serverId}|${t.id}|${t.localAddress}|${t.remoteAddress}`
|
||||||
const baseProbe = greTunnelProbe(t)
|
const baseProbe = greTunnelProbe(t)
|
||||||
|
const groupKey = peer?.id ?? (normalizeGreEndpointAddr(t.remoteAddress) || t.remoteAddress)
|
||||||
|
const isPrimary = grePrimaryByGroupKey.get(groupKey) === t.id
|
||||||
const spGre =
|
const spGre =
|
||||||
fromServer && toServer ? speedProbeByTunnelId.get(tunnelPanelKey) : undefined
|
fromServer && toServer ? speedProbeByTunnelId.get(tunnelPanelKey) : undefined
|
||||||
const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe)
|
const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe)
|
||||||
@@ -2197,9 +2237,17 @@ export default function NetworkMapPage() {
|
|||||||
<div key={tunnelPanelKey} className="rounded-md border border-border/60 px-3 py-2 bg-muted/20">
|
<div key={tunnelPanelKey} className="rounded-md border border-border/60 px-3 py-2 bg-muted/20">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="text-xs font-mono font-medium">{t.name}</span>
|
<span className="text-xs font-mono font-medium">{t.name}</span>
|
||||||
<span className="text-[10px] font-medium" style={{ color: ts.stroke }}>
|
<div className="flex items-center gap-2">
|
||||||
{t.status === "up" ? "Up" : t.status === "degraded" ? "Degraded" : "Down"}
|
<span className={cn(
|
||||||
</span>
|
"text-[10px] font-medium px-1.5 py-0.5 rounded-full",
|
||||||
|
isPrimary ? "bg-emerald-500/10 text-emerald-400" : "bg-muted text-muted-foreground",
|
||||||
|
)}>
|
||||||
|
{isPrimary ? "primary" : "backup"}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-medium" style={{ color: ts.stroke }}>
|
||||||
|
{t.status === "up" ? "Up" : t.status === "degraded" ? "Degraded" : "Down"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[200px]">
|
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[200px]">
|
||||||
@@ -2317,7 +2365,7 @@ export default function NetworkMapPage() {
|
|||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between py-1.5">
|
<div className="flex items-center justify-between py-1.5">
|
||||||
<span className="text-[10px] text-muted-foreground">Плата</span>
|
<span className="text-[10px] text-muted-foreground">Плата</span>
|
||||||
<span className="text-[10px] font-mono">{BOARD_MAP[selected.type]}</span>
|
<span className="text-[10px] font-mono">{selected.model || "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { PageHeader } from "@/components/page-header"
|
|||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon,
|
RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon,
|
||||||
CheckIcon, NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon,
|
NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
@@ -664,7 +665,6 @@ function InterfacesTab({
|
|||||||
const [items, setItems] = useState<OspfItem[]>(initialItems)
|
const [items, setItems] = useState<OspfItem[]>(initialItems)
|
||||||
const [dragging, setDragging] = useState<string | null>(null)
|
const [dragging, setDragging] = useState<string | null>(null)
|
||||||
const [dragOver, setDragOver] = useState<string | null>(null)
|
const [dragOver, setDragOver] = useState<string | null>(null)
|
||||||
const [toast, setToast] = useState<string | null>(null)
|
|
||||||
const [optimizing, setOptimizing] = useState(false)
|
const [optimizing, setOptimizing] = useState(false)
|
||||||
const [liveOptimalCost, setLiveOptimalCost] = useState<Record<string, number>>({})
|
const [liveOptimalCost, setLiveOptimalCost] = useState<Record<string, number>>({})
|
||||||
|
|
||||||
@@ -673,8 +673,6 @@ function InterfacesTab({
|
|||||||
queueMicrotask(() => setItems(initialItems))
|
queueMicrotask(() => setItems(initialItems))
|
||||||
}, [initialItems])
|
}, [initialItems])
|
||||||
|
|
||||||
function showToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 2500) }
|
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const byRouter: Record<string, { routerKey: string; routerLabel: string; areas: Record<string, OspfItem[]> }> = {}
|
const byRouter: Record<string, { routerKey: string; routerLabel: string; areas: Record<string, OspfItem[]> }> = {}
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
@@ -732,7 +730,7 @@ function InterfacesTab({
|
|||||||
|
|
||||||
async function handleLiveOptimize() {
|
async function handleLiveOptimize() {
|
||||||
if (!canOptimizeLive) {
|
if (!canOptimizeLive) {
|
||||||
showToast("Выберите конкретный сервер для оптимизации OSPF")
|
toast.info("Выберите конкретный сервер для оптимизации OSPF")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const ra = readStoredRouteOptimizerSettings()
|
const ra = readStoredRouteOptimizerSettings()
|
||||||
@@ -750,10 +748,10 @@ function InterfacesTab({
|
|||||||
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
|
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
|
||||||
})
|
})
|
||||||
setLiveOptimalCost(byKey)
|
setLiveOptimalCost(byKey)
|
||||||
showToast(`OSPF оптимизация применена: ${data.optimizedCount} интерфейсов`)
|
toast.success(`OSPF оптимизация применена: ${data.optimizedCount} интерфейсов`)
|
||||||
onLiveDataRefresh()
|
onLiveDataRefresh()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(`Ошибка оптимизации OSPF: ${err instanceof Error ? err.message : String(err)}`)
|
toast.error(`Ошибка оптимизации OSPF: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
} finally {
|
} finally {
|
||||||
setOptimizing(false)
|
setOptimizing(false)
|
||||||
}
|
}
|
||||||
@@ -777,7 +775,7 @@ function InterfacesTab({
|
|||||||
const h = hints[item.key]
|
const h = hints[item.key]
|
||||||
return h && item.cost !== h.optimalCost ? { ...item, cost: h.optimalCost } : item
|
return h && item.cost !== h.optimalCost ? { ...item, cost: h.optimalCost } : item
|
||||||
}))
|
}))
|
||||||
showToast("Costs оптимизированы по рекомендациям оптимизатора")
|
toast.success("Costs оптимизированы по рекомендациям оптимизатора")
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -798,19 +796,12 @@ function InterfacesTab({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!isLive && (
|
{!isLive && (
|
||||||
<Button size="sm" onClick={() => showToast("OSPF Interface Templates применены")}>
|
<Button size="sm" onClick={() => toast.success("OSPF Interface Templates применены")}>
|
||||||
<SaveIcon className="size-4" />Сохранить
|
<SaveIcon className="size-4" />Сохранить
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{toast && (
|
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-current/20 px-4 py-2.5 text-sm"
|
|
||||||
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>
|
|
||||||
<CheckIcon className="size-4 shrink-0" />{toast}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{grouped.length === 0 && (
|
{grouped.length === 0 && (
|
||||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
OSPF интерфейсы не настроены ни на одном сервере
|
OSPF интерфейсы не настроены ни на одном сервере
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -871,8 +872,15 @@ export default function SettingsPage() {
|
|||||||
const handleCopy = (text: string) => {
|
const handleCopy = (text: string) => {
|
||||||
navigator.clipboard.writeText(text).catch(() => {})
|
navigator.clipboard.writeText(text).catch(() => {})
|
||||||
setCopied(text); setTimeout(() => setCopied(null), 1500)
|
setCopied(text); setTimeout(() => setCopied(null), 1500)
|
||||||
|
toast.success("Скопировано в буфер обмена")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markSaved = useCallback(() => {
|
||||||
|
setSaved(true)
|
||||||
|
toast.success("Настройки сохранены")
|
||||||
|
setTimeout(() => setSaved(false), 2000)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleUserSave = (form: UserForm) => {
|
const handleUserSave = (form: UserForm) => {
|
||||||
if (!editUser) {
|
if (!editUser) {
|
||||||
const initials = form.name.split(" ").map(p => p[0] ?? "").slice(0, 2).join("").toUpperCase()
|
const initials = form.name.split(" ").map(p => p[0] ?? "").slice(0, 2).join("").toUpperCase()
|
||||||
@@ -905,8 +913,7 @@ export default function SettingsPage() {
|
|||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (section === "EvoBGP") {
|
if (section === "EvoBGP") {
|
||||||
if (mode !== "live" || backendStatus !== true) {
|
if (mode !== "live" || backendStatus !== true) {
|
||||||
setSaved(true)
|
markSaved()
|
||||||
setTimeout(() => setSaved(false), 2000)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setEvoSaveBusy(true)
|
setEvoSaveBusy(true)
|
||||||
@@ -919,8 +926,7 @@ export default function SettingsPage() {
|
|||||||
if (evoKeyDraft.trim()) patch.apiKey = evoKeyDraft.trim()
|
if (evoKeyDraft.trim()) patch.apiKey = evoKeyDraft.trim()
|
||||||
await evo.saveSettings(patch)
|
await evo.saveSettings(patch)
|
||||||
setEvoKeyDraft("")
|
setEvoKeyDraft("")
|
||||||
setSaved(true)
|
markSaved()
|
||||||
setTimeout(() => setSaved(false), 2000)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка сохранения")
|
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка сохранения")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -928,8 +934,7 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSaved(true)
|
markSaved()
|
||||||
setTimeout(() => setSaved(false), 2000)
|
|
||||||
}, [
|
}, [
|
||||||
section,
|
section,
|
||||||
mode,
|
mode,
|
||||||
@@ -937,6 +942,7 @@ export default function SettingsPage() {
|
|||||||
evoBaseDraft,
|
evoBaseDraft,
|
||||||
evoEnabledDraft,
|
evoEnabledDraft,
|
||||||
evoKeyDraft,
|
evoKeyDraft,
|
||||||
|
markSaved,
|
||||||
evo.saveSettings,
|
evo.saveSettings,
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -1212,8 +1218,7 @@ export default function SettingsPage() {
|
|||||||
try {
|
try {
|
||||||
await evo.saveSettings({ apiKey: null })
|
await evo.saveSettings({ apiKey: null })
|
||||||
setEvoKeyDraft("")
|
setEvoKeyDraft("")
|
||||||
setSaved(true)
|
markSaved()
|
||||||
setTimeout(() => setSaved(false), 2000)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка")
|
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка")
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+19
-15
@@ -5,6 +5,7 @@ import { PageHeader } from "@/components/page-header"
|
|||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { StatusDot } from "@/components/status-dot"
|
import { StatusDot } from "@/components/status-dot"
|
||||||
import { Sparkline } from "@/components/sparkline"
|
import { Sparkline } from "@/components/sparkline"
|
||||||
@@ -1001,12 +1002,12 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
|
|||||||
|
|
||||||
{/* ── Alert banner ──────────────────────────────────────────────────── */}
|
{/* ── Alert banner ──────────────────────────────────────────────────── */}
|
||||||
{alerts.length > 0 && (
|
{alerts.length > 0 && (
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-red-500/30 bg-red-500/5 px-4 py-3">
|
<Alert variant="destructive">
|
||||||
<ServerCrashIcon className="size-4 text-red-500 shrink-0 mt-0.5" />
|
<ServerCrashIcon />
|
||||||
<div className="flex-1 min-w-0">
|
<AlertTitle className="text-xs mb-1">
|
||||||
<p className="text-xs font-semibold text-red-600 dark:text-red-400 mb-1">
|
{alerts.length} {alerts.length === 1 ? "сервер требует внимания" : "сервера требуют внимания"}
|
||||||
{alerts.length} {alerts.length === 1 ? "сервер требует внимания" : "сервера требуют внимания"}
|
</AlertTitle>
|
||||||
</p>
|
<AlertDescription>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{alerts.map(r => {
|
{alerts.map(r => {
|
||||||
const s = r.server!
|
const s = r.server!
|
||||||
@@ -1016,16 +1017,15 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
|
|||||||
if (r.hddPct >= 85) issues.push(`HDD ${r.hddPct}%`)
|
if (r.hddPct >= 85) issues.push(`HDD ${r.hddPct}%`)
|
||||||
if ((r.temp ?? 0) >= 70) issues.push(`${r.temp}°C`)
|
if ((r.temp ?? 0) >= 70) issues.push(`${r.temp}°C`)
|
||||||
return (
|
return (
|
||||||
<span key={r.serverId} className="inline-flex items-center gap-1 text-[11px] font-mono
|
<span key={r.serverId} className="inline-flex items-center gap-1 text-[11px] font-mono rounded border border-destructive/30 bg-destructive/10 px-2 py-0.5">
|
||||||
rounded border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-red-400">
|
|
||||||
<Flag code={s.country} size={10} />
|
<Flag code={s.country} size={10} />
|
||||||
{s.name} — {issues.join(", ")}
|
{s.name} — {issues.join(", ")}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AlertDescription>
|
||||||
</div>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── KPI summary ───────────────────────────────────────────────────── */}
|
{/* ── KPI summary ───────────────────────────────────────────────────── */}
|
||||||
@@ -2330,8 +2330,11 @@ export default function UptimePage() {
|
|||||||
|
|
||||||
{/* ── error ── */}
|
{/* ── error ── */}
|
||||||
{speedError && (
|
{speedError && (
|
||||||
<div className="mx-6 mt-4 text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
<div className="mx-6 mt-4">
|
||||||
{speedError}
|
<Alert variant="destructive" className="py-2 text-xs">
|
||||||
|
<AlertCircleIcon />
|
||||||
|
<AlertDescription>{speedError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2650,9 +2653,10 @@ export default function UptimePage() {
|
|||||||
|
|
||||||
{opError && (
|
{opError && (
|
||||||
<div className="px-6 pt-4">
|
<div className="px-6 pt-4">
|
||||||
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
<Alert variant="destructive" className="py-2 text-xs">
|
||||||
{opError}
|
<AlertCircleIcon />
|
||||||
</div>
|
<AlertDescription>{opError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -32,7 +33,10 @@ export default function RootLayout({
|
|||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">
|
<body className="min-h-full flex flex-col">
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<TooltipProvider>{children}</TooltipProvider>
|
<TooltipProvider>
|
||||||
|
{children}
|
||||||
|
<Toaster position="top-right" />
|
||||||
|
</TooltipProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -205,6 +205,23 @@ CREATE TABLE IF NOT EXISTS scheduler_runs (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time
|
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time
|
||||||
ON scheduler_runs(job_key, finished_at);
|
ON scheduler_runs(job_key, finished_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
source_module TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
entity_type TEXT,
|
||||||
|
entity_id TEXT,
|
||||||
|
payload_json TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_level_created_at ON events(level, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_source_created_at ON events(source_module, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_event_type_created_at ON events(event_type, created_at DESC);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
|
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
enabled INTEGER NOT NULL DEFAULT 0,
|
enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|||||||
@@ -426,6 +426,20 @@ export const schedulerRuns = sqliteTable("scheduler_runs", {
|
|||||||
resultJson: text("result_json"),
|
resultJson: text("result_json"),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Централизованный append-only журнал событий системы. */
|
||||||
|
export const events = sqliteTable("events", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
level: text("level", { enum: ["critical", "warning", "info"] }).notNull(),
|
||||||
|
eventType: text("event_type").notNull(),
|
||||||
|
sourceModule: text("source_module").notNull(),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
message: text("message").notNull(),
|
||||||
|
entityType: text("entity_type"),
|
||||||
|
entityId: text("entity_id"),
|
||||||
|
payloadJson: text("payload_json"),
|
||||||
|
})
|
||||||
|
|
||||||
export const uptimeSpeedTestRuns = sqliteTable("uptime_speed_test_runs", {
|
export const uptimeSpeedTestRuns = sqliteTable("uptime_speed_test_runs", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
probeId: text("probe_id"),
|
probeId: text("probe_id"),
|
||||||
@@ -468,6 +482,7 @@ export type UptimeResourceSampleRow = typeof uptimeResourceSamples.$inferSelect
|
|||||||
export type UptimeSpeedProbeRow = typeof uptimeSpeedProbes.$inferSelect
|
export type UptimeSpeedProbeRow = typeof uptimeSpeedProbes.$inferSelect
|
||||||
export type UptimeSpeedTestRunRow = typeof uptimeSpeedTestRuns.$inferSelect
|
export type UptimeSpeedTestRunRow = typeof uptimeSpeedTestRuns.$inferSelect
|
||||||
export type SchedulerRunRow = typeof schedulerRuns.$inferSelect
|
export type SchedulerRunRow = typeof schedulerRuns.$inferSelect
|
||||||
|
export type EventRow = typeof events.$inferSelect
|
||||||
export type EvobgpSettingsRow = typeof evobgpSettings.$inferSelect
|
export type EvobgpSettingsRow = typeof evobgpSettings.$inferSelect
|
||||||
export type AlertTelegramSettingsRow = typeof alertTelegramSettings.$inferSelect
|
export type AlertTelegramSettingsRow = typeof alertTelegramSettings.$inferSelect
|
||||||
export type AlertGroupRow = typeof alertGroups.$inferSelect
|
export type AlertGroupRow = typeof alertGroups.$inferSelect
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import schedulerRoutes from "./routes/scheduler.js"
|
|||||||
import sidebarCountsRoutes from "./routes/sidebar-counts.js"
|
import sidebarCountsRoutes from "./routes/sidebar-counts.js"
|
||||||
import alertsRoutes from "./routes/alerts.js"
|
import alertsRoutes from "./routes/alerts.js"
|
||||||
import backupsRoutes from "./routes/backups.js"
|
import backupsRoutes from "./routes/backups.js"
|
||||||
|
import eventsRoutes from "./routes/events.js"
|
||||||
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
||||||
|
|
||||||
// ── app factory ────────────────────────────────────────────────────────────────
|
// ── app factory ────────────────────────────────────────────────────────────────
|
||||||
@@ -62,6 +63,7 @@ await app.register(schedulerRoutes, { prefix: "/api" })
|
|||||||
await app.register(sidebarCountsRoutes, { prefix: "/api" })
|
await app.register(sidebarCountsRoutes, { prefix: "/api" })
|
||||||
await app.register(alertsRoutes, { prefix: "/api" })
|
await app.register(alertsRoutes, { prefix: "/api" })
|
||||||
await app.register(backupsRoutes, { prefix: "/api" })
|
await app.register(backupsRoutes, { prefix: "/api" })
|
||||||
|
await app.register(eventsRoutes, { prefix: "/api" })
|
||||||
|
|
||||||
refreshScheduler()
|
refreshScheduler()
|
||||||
app.addHook("onClose", async () => {
|
app.addHook("onClose", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { and, desc, eq, gte, lte } from "drizzle-orm"
|
||||||
|
import { db } from "../../../db/index.js"
|
||||||
|
import { events } from "../../../db/schema.js"
|
||||||
|
import type { EventItem, EventLevel, EventSourceModule } from "../../../../../packages/contracts/dist/events.js"
|
||||||
|
|
||||||
|
export type EventInsertInput = {
|
||||||
|
id: string
|
||||||
|
createdAt: string
|
||||||
|
level: EventLevel
|
||||||
|
eventType: string
|
||||||
|
sourceModule: EventSourceModule
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
entityType?: string
|
||||||
|
entityId?: string
|
||||||
|
payload?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListEventsParams = {
|
||||||
|
limit: number
|
||||||
|
level?: EventLevel
|
||||||
|
sourceModule?: EventSourceModule
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePayload(payloadJson: string | null): Record<string, unknown> {
|
||||||
|
if (!payloadJson) return {}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payloadJson) as unknown
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||||
|
return parsed as Record<string, unknown>
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// keep backwards compatibility with malformed legacy payloads
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapEventRow(row: typeof events.$inferSelect): EventItem {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
level: row.level,
|
||||||
|
eventType: row.eventType,
|
||||||
|
sourceModule: row.sourceModule as EventSourceModule,
|
||||||
|
title: row.title,
|
||||||
|
message: row.message,
|
||||||
|
entityType: row.entityType ?? null,
|
||||||
|
entityId: row.entityId ?? null,
|
||||||
|
payload: parsePayload(row.payloadJson ?? null),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertEventsBatch(items: EventInsertInput[]) {
|
||||||
|
if (items.length === 0) return
|
||||||
|
db.insert(events)
|
||||||
|
.values(
|
||||||
|
items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
level: item.level,
|
||||||
|
eventType: item.eventType,
|
||||||
|
sourceModule: item.sourceModule,
|
||||||
|
title: item.title,
|
||||||
|
message: item.message,
|
||||||
|
entityType: item.entityType ?? null,
|
||||||
|
entityId: item.entityId ?? null,
|
||||||
|
payloadJson: item.payload ? JSON.stringify(item.payload) : null,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listEvents(params: ListEventsParams): EventItem[] {
|
||||||
|
const where = and(
|
||||||
|
params.level ? eq(events.level, params.level) : undefined,
|
||||||
|
params.sourceModule ? eq(events.sourceModule, params.sourceModule) : undefined,
|
||||||
|
params.from ? gte(events.createdAt, params.from) : undefined,
|
||||||
|
params.to ? lte(events.createdAt, params.to) : undefined,
|
||||||
|
)
|
||||||
|
const rows = db
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(events.createdAt))
|
||||||
|
.limit(params.limit)
|
||||||
|
.all()
|
||||||
|
return rows.map(mapEventRow)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
import {
|
||||||
|
appendEventSchema,
|
||||||
|
listEventsQuerySchema,
|
||||||
|
type AppendEventBody,
|
||||||
|
type EventItem,
|
||||||
|
type ListEventsQuery,
|
||||||
|
} from "../../../../../packages/contracts/dist/events.js"
|
||||||
|
import { insertEventsBatch, listEvents, type EventInsertInput } from "../repository/events-repository.js"
|
||||||
|
|
||||||
|
function normalizeEventInput(input: AppendEventBody): EventInsertInput {
|
||||||
|
return {
|
||||||
|
id: randomUUID(),
|
||||||
|
createdAt: input.createdAt ?? new Date().toISOString(),
|
||||||
|
level: input.level,
|
||||||
|
eventType: input.eventType.trim(),
|
||||||
|
sourceModule: input.sourceModule,
|
||||||
|
title: input.title.trim(),
|
||||||
|
message: input.message.trim(),
|
||||||
|
entityType: input.entityType?.trim() || undefined,
|
||||||
|
entityId: input.entityId?.trim() || undefined,
|
||||||
|
payload: input.payload ?? {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendEvent(input: AppendEventBody) {
|
||||||
|
const parsed = appendEventSchema.parse(input)
|
||||||
|
insertEventsBatch([normalizeEventInput(parsed)])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendEvents(inputs: AppendEventBody[]) {
|
||||||
|
const rows = inputs.map((entry) => normalizeEventInput(appendEventSchema.parse(entry)))
|
||||||
|
insertEventsBatch(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readEvents(query: Partial<ListEventsQuery>): EventItem[] {
|
||||||
|
const parsed = listEventsQuerySchema.parse(query)
|
||||||
|
return listEvents(parsed)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
|||||||
import { listServersRead } from "../modules/servers/service/servers-service.js"
|
import { listServersRead } from "../modules/servers/service/servers-service.js"
|
||||||
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
|
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
|
||||||
import { MikrotikClient } from "../services/mikrotik.js"
|
import { MikrotikClient } from "../services/mikrotik.js"
|
||||||
|
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||||
|
|
||||||
type BackupMeta = {
|
type BackupMeta = {
|
||||||
id: string
|
id: string
|
||||||
@@ -121,6 +122,21 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
|
|||||||
await writeIndex(indexRows)
|
await writeIndex(indexRows)
|
||||||
job.status = "done"
|
job.status = "done"
|
||||||
job.finishedAt = new Date().toISOString()
|
job.finishedAt = new Date().toISOString()
|
||||||
|
appendEvent({
|
||||||
|
level: job.failures.length > 0 ? "warning" : "info",
|
||||||
|
eventType: "backups.job.done",
|
||||||
|
sourceModule: "backups",
|
||||||
|
title: job.failures.length > 0 ? "Бэкап завершен с ошибками" : "Бэкап завершен",
|
||||||
|
message: `Создано: ${job.created.length}, ошибок: ${job.failures.length}`,
|
||||||
|
entityType: "backup_job",
|
||||||
|
entityId: job.id,
|
||||||
|
payload: {
|
||||||
|
total: job.total,
|
||||||
|
completed: job.completed,
|
||||||
|
failures: job.failures,
|
||||||
|
notes: notes ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
@@ -148,13 +164,40 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
failures: [],
|
failures: [],
|
||||||
}
|
}
|
||||||
backupJobs.set(jobId, job)
|
backupJobs.set(jobId, job)
|
||||||
|
appendEvent({
|
||||||
|
level: "info",
|
||||||
|
eventType: "backups.job.started",
|
||||||
|
sourceModule: "backups",
|
||||||
|
title: "Запущен бэкап",
|
||||||
|
message: `Серверов в очереди: ${ids.length}`,
|
||||||
|
entityType: "backup_job",
|
||||||
|
entityId: jobId,
|
||||||
|
payload: {
|
||||||
|
serverIds: ids,
|
||||||
|
notes: notes ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
void processBackupJob(job, ids, notes).catch((err) => {
|
void processBackupJob(job, ids, notes).catch((err) => {
|
||||||
job.status = "failed"
|
job.status = "failed"
|
||||||
job.finishedAt = new Date().toISOString()
|
job.finishedAt = new Date().toISOString()
|
||||||
job.failures.push({
|
const failure = {
|
||||||
serverId: "job",
|
serverId: "job",
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
}
|
||||||
|
job.failures.push(failure)
|
||||||
|
appendEvent({
|
||||||
|
level: "critical",
|
||||||
|
eventType: "backups.job.failed",
|
||||||
|
sourceModule: "backups",
|
||||||
|
title: "Бэкап прерван",
|
||||||
|
message: failure.error,
|
||||||
|
entityType: "backup_job",
|
||||||
|
entityId: job.id,
|
||||||
|
payload: {
|
||||||
|
total: job.total,
|
||||||
|
completed: job.completed,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
|
import { appendEventsSchema, listEventsQuerySchema } from "../../../packages/contracts/dist/events.js"
|
||||||
|
import { appendEvents, readEvents } from "../modules/events/service/events-service.js"
|
||||||
|
|
||||||
|
const eventsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
|
app.get("/events", async (req, reply) => {
|
||||||
|
const parsed = listEventsQuerySchema.safeParse(req.query ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректные параметры запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
return reply.send({ events: readEvents(parsed.data) })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/events/batch", async (req, reply) => {
|
||||||
|
const parsed = appendEventsSchema.safeParse(req.body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
appendEvents(parsed.data.events)
|
||||||
|
return reply.status(201).send({ ok: true, inserted: parsed.data.events.length })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default eventsRoutes
|
||||||
+230
-41
@@ -4,6 +4,7 @@ import { db } from "../db/index.js"
|
|||||||
import { filterRules, recursiveRoutes, servers } from "../db/schema.js"
|
import { filterRules, recursiveRoutes, servers } from "../db/schema.js"
|
||||||
import { MikrotikClient } from "../services/mikrotik.js"
|
import { MikrotikClient } from "../services/mikrotik.js"
|
||||||
import { parseDbServerId } from "../utils/server-id.js"
|
import { parseDbServerId } from "../utils/server-id.js"
|
||||||
|
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||||
|
|
||||||
type ServerRow = typeof servers.$inferSelect
|
type ServerRow = typeof servers.$inferSelect
|
||||||
|
|
||||||
@@ -87,7 +88,6 @@ function parseInnerIps(rule: string | undefined): { localInnerIp: string; remote
|
|||||||
|
|
||||||
function parseFilterRule(raw: RosFilterRule): ApiFilterRule[] {
|
function parseFilterRule(raw: RosFilterRule): ApiFilterRule[] {
|
||||||
const text = raw.rule ?? ""
|
const text = raw.rule ?? ""
|
||||||
const inlineComment = text.match(/#\s*([^\n]+)/)?.[1]?.trim()
|
|
||||||
const out: ApiFilterRule[] = []
|
const out: ApiFilterRule[] = []
|
||||||
|
|
||||||
const extractCommunities = (src: string): string[] => {
|
const extractCommunities = (src: string): string[] => {
|
||||||
@@ -98,6 +98,25 @@ function parseFilterRule(raw: RosFilterRule): ApiFilterRule[] {
|
|||||||
return [...new Set(communities)]
|
return [...new Set(communities)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-community описания: `# 65001:130: Torrents` → { "65001:130": "Torrents" }.
|
||||||
|
* Используется при round-trip Router → DB, чтобы не схлопывать описания всех
|
||||||
|
* членов сгруппированного блока в одну общую подпись.
|
||||||
|
*/
|
||||||
|
const extractDescMap = (src: string): Map<string, string> => {
|
||||||
|
const m = new Map<string, string>()
|
||||||
|
for (const cm of src.matchAll(/#\s*(\d+:\d+)\s*:\s*([^\n]+)/g)) {
|
||||||
|
m.set(cm[1], cm[2].trim())
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Первый произвольный `#` комментарий — fallback, если нет per-community. */
|
||||||
|
const firstInlineComment = (src: string): string =>
|
||||||
|
src.match(/#\s*([^\n]+)/)?.[1]?.trim() ?? ""
|
||||||
|
|
||||||
|
const rawComment = raw.comment?.trim() ?? ""
|
||||||
|
|
||||||
// Парсим по веткам if/else if, чтобы action применялся к "своим" communities.
|
// Парсим по веткам if/else if, чтобы action применялся к "своим" communities.
|
||||||
const branches = [...text.matchAll(/(?:if|else\s+if)\s*\(([\s\S]*?)\)\s*\{([\s\S]*?)\}/gi)]
|
const branches = [...text.matchAll(/(?:if|else\s+if)\s*\(([\s\S]*?)\)\s*\{([\s\S]*?)\}/gi)]
|
||||||
if (branches.length > 0) {
|
if (branches.length > 0) {
|
||||||
@@ -113,14 +132,18 @@ function parseFilterRule(raw: RosFilterRule): ApiFilterRule[] {
|
|||||||
const gwToken = body.match(/set\s+gw(?:ateway)?\s+([^\s;]+)/i)?.[1] ?? ""
|
const gwToken = body.match(/set\s+gw(?:ateway)?\s+([^\s;]+)/i)?.[1] ?? ""
|
||||||
const outIface = body.match(/set\s+out-interface\s+([^\s;]+)/i)?.[1] ?? ""
|
const outIface = body.match(/set\s+out-interface\s+([^\s;]+)/i)?.[1] ?? ""
|
||||||
|
|
||||||
|
const descMap = extractDescMap(body)
|
||||||
|
const fallbackInline = firstInlineComment(body)
|
||||||
|
|
||||||
for (const community of comms) {
|
for (const community of comms) {
|
||||||
|
const perComm = descMap.get(community)?.trim() ?? ""
|
||||||
out.push({
|
out.push({
|
||||||
id: `${raw[".id"] ?? "live"}-${out.length}`,
|
id: `${raw[".id"] ?? "live"}-${out.length}`,
|
||||||
community,
|
community,
|
||||||
action: isBlackhole ? "blackhole" : "route",
|
action: isBlackhole ? "blackhole" : "route",
|
||||||
gateway: isBlackhole ? "" : gwToken,
|
gateway: isBlackhole ? "" : gwToken,
|
||||||
gatewayTunnelId: isBlackhole ? "" : outIface,
|
gatewayTunnelId: isBlackhole ? "" : outIface,
|
||||||
description: inlineComment || raw.comment?.trim() || "",
|
description: perComm || fallbackInline || rawComment,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,14 +158,19 @@ function parseFilterRule(raw: RosFilterRule): ApiFilterRule[] {
|
|||||||
/set\s+blackhole\s+(?:yes|true)/i.test(text)
|
/set\s+blackhole\s+(?:yes|true)/i.test(text)
|
||||||
const gwToken = text.match(/set\s+gw(?:ateway)?\s+([^\s;]+)/i)?.[1] ?? ""
|
const gwToken = text.match(/set\s+gw(?:ateway)?\s+([^\s;]+)/i)?.[1] ?? ""
|
||||||
const outIface = text.match(/set\s+out-interface\s+([^\s;]+)/i)?.[1] ?? ""
|
const outIface = text.match(/set\s+out-interface\s+([^\s;]+)/i)?.[1] ?? ""
|
||||||
return communities.map((community, idx) => ({
|
const flatDescMap = extractDescMap(text)
|
||||||
id: `${raw[".id"] ?? "live"}-${idx}`,
|
const flatFallback = firstInlineComment(text)
|
||||||
community,
|
return communities.map((community, idx) => {
|
||||||
action: isBlackhole ? "blackhole" : "route",
|
const perComm = flatDescMap.get(community)?.trim() ?? ""
|
||||||
gateway: isBlackhole ? "" : gwToken,
|
return {
|
||||||
gatewayTunnelId: isBlackhole ? "" : outIface,
|
id: `${raw[".id"] ?? "live"}-${idx}`,
|
||||||
description: inlineComment || raw.comment?.trim() || "",
|
community,
|
||||||
}))
|
action: isBlackhole ? "blackhole" : "route",
|
||||||
|
gateway: isBlackhole ? "" : gwToken,
|
||||||
|
gatewayTunnelId: isBlackhole ? "" : outIface,
|
||||||
|
description: perComm || flatFallback || rawComment,
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Хоп для BGP filter из префикса рекурсивного статического маршрута (напр. 10.9.9.2/32 → 10.9.9.2) */
|
/** Хоп для BGP filter из префикса рекурсивного статического маршрута (напр. 10.9.9.2/32 → 10.9.9.2) */
|
||||||
@@ -308,26 +336,66 @@ function compareDbRulesWithRouter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): string {
|
function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): string {
|
||||||
return rules.map((rule, i) => {
|
if (rules.length === 0) return ""
|
||||||
const kw = i === 0 ? "if" : "} else if"
|
// Группируем по эффекту (action + gateway + out-interface). Communities с одним и тем же
|
||||||
const comment = rule.description ? ` # ${rule.description}` : ""
|
// `set gw` объединяются через `||` в один if-блок — компактнее и ближе к привычному
|
||||||
if (rule.action === "blackhole") {
|
// синтаксису bgp-in на MikroTik.
|
||||||
|
// RouterOS routing filter parser НЕ поддерживает `else if` (см. error
|
||||||
|
// «expected '{' instead of 'if'»), поэтому между группами — независимые `if`-блоки;
|
||||||
|
// после `accept;` обработка правила завершается, следующие `if`-ы не запускаются.
|
||||||
|
// Эталон тела: `bgp-communities includes <v>`, `set gw <ip>`.
|
||||||
|
type Group = {
|
||||||
|
isBlackhole: boolean
|
||||||
|
gateway: string
|
||||||
|
outIface: string
|
||||||
|
items: ApiFilterRule[]
|
||||||
|
}
|
||||||
|
const groups: Group[] = []
|
||||||
|
const indexByKey = new Map<string, number>()
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
const isBlackhole = rule.action === "blackhole"
|
||||||
|
const { gateway, outIface } = isBlackhole
|
||||||
|
? { gateway: "", outIface: "" }
|
||||||
|
: resolveRouteTargets(serverId, rule)
|
||||||
|
const key = isBlackhole ? "bh" : `rt:${gateway}:${outIface}`
|
||||||
|
let idx = indexByKey.get(key)
|
||||||
|
if (idx === undefined) {
|
||||||
|
idx = groups.length
|
||||||
|
indexByKey.set(key, idx)
|
||||||
|
groups.push({ isBlackhole, gateway, outIface, items: [] })
|
||||||
|
}
|
||||||
|
groups[idx].items.push(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.map(g => {
|
||||||
|
const cond = g.items
|
||||||
|
.map(r => `bgp-communities includes ${r.community}`)
|
||||||
|
.join(" || ")
|
||||||
|
// Per-community подпись `# 65001:130: Torrents` — на роутере человеко-читаемо,
|
||||||
|
// и parseFilterRule умеет извлечь её обратно в description конкретного правила.
|
||||||
|
const commentLines = g.items
|
||||||
|
.filter(r => r.description?.trim())
|
||||||
|
.map(r => ` # ${r.community}: ${r.description.trim()}`)
|
||||||
|
|
||||||
|
if (g.isBlackhole) {
|
||||||
return [
|
return [
|
||||||
` ${kw} (bgp-communities.has("${rule.community}")) {`,
|
`if (${cond}) {`,
|
||||||
comment,
|
...commentLines,
|
||||||
" set type blackhole;",
|
" set type blackhole;",
|
||||||
" accept;",
|
" accept;",
|
||||||
].filter(Boolean).join("\n")
|
"}",
|
||||||
|
].join("\n")
|
||||||
}
|
}
|
||||||
const { gateway, outIface } = resolveRouteTargets(serverId, rule)
|
|
||||||
const lines = [
|
const lines = [
|
||||||
` ${kw} (bgp-communities.has("${rule.community}")) {`,
|
`if (${cond}) {`,
|
||||||
comment,
|
...commentLines,
|
||||||
` set gateway ${gateway};`,
|
` set gw ${g.gateway};`,
|
||||||
]
|
]
|
||||||
if (outIface) lines.push(` set out-interface ${outIface};`)
|
if (g.outIface) lines.push(` set out-interface ${g.outIface};`)
|
||||||
lines.push(" accept;")
|
lines.push(" accept;")
|
||||||
return lines.filter(Boolean).join("\n")
|
lines.push("}")
|
||||||
|
return lines.join("\n")
|
||||||
}).join("\n")
|
}).join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,30 +525,94 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
app.log.info({ serverId, host: server.host }, "Filters sync from router started")
|
app.log.info({ serverId, host: server.host }, "Filters sync from router started")
|
||||||
|
appendEvent({
|
||||||
|
level: "info",
|
||||||
|
eventType: "filters.sync.from_router.started",
|
||||||
|
sourceModule: "filters",
|
||||||
|
title: "Синхронизация фильтров запущена",
|
||||||
|
message: `${server.name || server.host} → БД`,
|
||||||
|
entityType: "server",
|
||||||
|
entityId: String(serverId),
|
||||||
|
})
|
||||||
const remote = await fetchServerFilters(server)
|
const remote = await fetchServerFilters(server)
|
||||||
await replaceDbRules(server.id, remote.rules)
|
await replaceDbRules(server.id, remote.rules)
|
||||||
app.log.info({ serverId, totalRules: remote.rules.length }, "Filters sync from router completed")
|
app.log.info({ serverId, totalRules: remote.rules.length }, "Filters sync from router completed")
|
||||||
|
appendEvent({
|
||||||
|
level: "info",
|
||||||
|
eventType: "filters.sync.from_router.done",
|
||||||
|
sourceModule: "filters",
|
||||||
|
title: "Синхронизация фильтров завершена",
|
||||||
|
message: `${server.name || server.host}: ${remote.rules.length} правил`,
|
||||||
|
entityType: "server",
|
||||||
|
entityId: String(serverId),
|
||||||
|
})
|
||||||
return reply.send({ ok: true, updatedServers: 1, totalRules: remote.rules.length, serverId })
|
return reply.send({ ok: true, updatedServers: 1, totalRules: remote.rules.length, serverId })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
app.log.error({ serverId, err: String(err) }, "Filters sync from router failed")
|
app.log.error({ serverId, err: String(err) }, "Filters sync from router failed")
|
||||||
|
appendEvent({
|
||||||
|
level: "critical",
|
||||||
|
eventType: "filters.sync.from_router.failed",
|
||||||
|
sourceModule: "filters",
|
||||||
|
title: "Ошибка синхронизации фильтров",
|
||||||
|
message: `${server.name || server.host}: ${String(err)}`,
|
||||||
|
entityType: "server",
|
||||||
|
entityId: String(serverId),
|
||||||
|
})
|
||||||
return reply.status(500).send({ error: String(err) })
|
return reply.status(500).send({ error: String(err) })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post("/filters/sync/to-router", async (_req, reply) => {
|
app.post("/filters/sync/to-router", async (req, reply) => {
|
||||||
|
const body = req.body as { serverId?: string | number } | undefined
|
||||||
|
const requestedServerId = parseDbServerId(body?.serverId)
|
||||||
|
|
||||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||||
|
const targetServers = requestedServerId !== null
|
||||||
|
? allServers.filter(s => s.id === requestedServerId)
|
||||||
|
: allServers
|
||||||
|
|
||||||
|
if (requestedServerId !== null && targetServers.length === 0) {
|
||||||
|
return reply.status(404).send({ error: "Server not found" })
|
||||||
|
}
|
||||||
|
|
||||||
let updatedServers = 0
|
let updatedServers = 0
|
||||||
let pushedRules = 0
|
let pushedRules = 0
|
||||||
|
const errors: Array<{ serverId: number; error: string }> = []
|
||||||
|
appendEvent({
|
||||||
|
level: "info",
|
||||||
|
eventType: "filters.sync.to_router.started",
|
||||||
|
sourceModule: "filters",
|
||||||
|
title: "Отправка фильтров на роутеры запущена",
|
||||||
|
message: `Целевых серверов: ${targetServers.length}`,
|
||||||
|
payload: { requestedServerId },
|
||||||
|
})
|
||||||
|
|
||||||
for (const server of allServers) {
|
for (const server of targetServers) {
|
||||||
try {
|
try {
|
||||||
|
app.log.info({ serverId: server.id, host: server.host }, "Filters sync to router started")
|
||||||
const client = MikrotikClient.fromServer(server)
|
const client = MikrotikClient.fromServer(server)
|
||||||
const existing = await client.get<RosFilterRule[]>("/routing/filter/rule")
|
const existing = await client.get<RosFilterRule[]>("/routing/filter/rule")
|
||||||
const managed = existing.filter(r => (r.comment ?? "").startsWith("RouterLists:"))
|
|
||||||
for (const r of managed) {
|
const isInBgpIn = (r: RosFilterRule) =>
|
||||||
if (!r[".id"]) continue
|
(r.chain ?? "").trim().toLowerCase() === "bgp-in"
|
||||||
await client.delete(`/routing/filter/rule/${encodeURIComponent(r[".id"])}`)
|
const managedComment = `RouterLists: ${server.name || server.host}`
|
||||||
}
|
|
||||||
|
// Уже созданное нами правило — будем PATCH'ить, чтобы сохранить ID/позицию в цепочке.
|
||||||
|
const managedRule = existing.find(
|
||||||
|
r => isInBgpIn(r) && (r.comment ?? "").startsWith("RouterLists:"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Конфликтующие легаси-правила в bgp-in (без нашего comment, но с bgp-communities) —
|
||||||
|
// удаляем после успешного upsert: иначе старое правило с `else { reject; }`
|
||||||
|
// отрабатывает первым и перебивает наш upsert.
|
||||||
|
const conflictIds = existing
|
||||||
|
.filter(r =>
|
||||||
|
isInBgpIn(r) &&
|
||||||
|
!(r.comment ?? "").startsWith("RouterLists:") &&
|
||||||
|
/bgp-communities/i.test(r.rule ?? ""),
|
||||||
|
)
|
||||||
|
.map(r => r[".id"])
|
||||||
|
.filter((id): id is string => Boolean(id))
|
||||||
|
|
||||||
const rows = db.select().from(filterRules)
|
const rows = db.select().from(filterRules)
|
||||||
.where(and(eq(filterRules.serverId, server.id)))
|
.where(and(eq(filterRules.serverId, server.id)))
|
||||||
@@ -497,22 +629,79 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
description: r.description,
|
description: r.description,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Upsert: PATCH существующего managed-правила или POST /add нового.
|
||||||
|
// Если ошибка — конфликтные правила НЕ удаляем (роутер не остаётся с пустым bgp-in).
|
||||||
|
// Путь `/routing/filter/rule/add` обязателен: голый POST на коллекцию RouterOS REST
|
||||||
|
// трактует как «вызов команды» и отдаёт 400 «no such command».
|
||||||
|
// См. https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API
|
||||||
if (rules.length > 0) {
|
if (rules.length > 0) {
|
||||||
const ruleBody = toRouterRuleBody(server.id, rules)
|
const ruleBody = toRouterRuleBody(server.id, rules)
|
||||||
await client.post("/routing/filter/rule", {
|
if (managedRule && managedRule[".id"]) {
|
||||||
chain: "bgp-in",
|
await client.patch(
|
||||||
comment: `RouterLists: ${server.name || server.host}`,
|
`/routing/filter/rule/${encodeURIComponent(managedRule[".id"])}`,
|
||||||
rule: ruleBody,
|
{
|
||||||
})
|
chain: "bgp-in",
|
||||||
|
comment: managedComment,
|
||||||
|
rule: ruleBody,
|
||||||
|
disabled: "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
app.log.info({ serverId: server.id, id: managedRule[".id"] }, "bgp-in rule updated")
|
||||||
|
} else {
|
||||||
|
await client.post("/routing/filter/rule/add", {
|
||||||
|
chain: "bgp-in",
|
||||||
|
comment: managedComment,
|
||||||
|
rule: ruleBody,
|
||||||
|
})
|
||||||
|
app.log.info({ serverId: server.id }, "bgp-in rule created")
|
||||||
|
}
|
||||||
pushedRules += rules.length
|
pushedRules += rules.length
|
||||||
|
} else if (managedRule && managedRule[".id"]) {
|
||||||
|
// В БД нет правил → удаляем наш managed-rule на роутере.
|
||||||
|
await client.delete(
|
||||||
|
`/routing/filter/rule/${encodeURIComponent(managedRule[".id"])}`,
|
||||||
|
)
|
||||||
|
app.log.info({ serverId: server.id }, "bgp-in rule removed (no rules in DB)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const id of conflictIds) {
|
||||||
|
await client.delete(`/routing/filter/rule/${encodeURIComponent(id)}`)
|
||||||
|
}
|
||||||
|
|
||||||
updatedServers += 1
|
updatedServers += 1
|
||||||
} catch {
|
app.log.info(
|
||||||
// ignore failed server push
|
{
|
||||||
|
serverId: server.id,
|
||||||
|
mode: managedRule ? "patch" : "create",
|
||||||
|
conflictsRemoved: conflictIds.length,
|
||||||
|
pushed: rules.length,
|
||||||
|
},
|
||||||
|
"Filters sync to router completed",
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error({ serverId: server.id, err: String(err) }, "filters sync to-router failed")
|
||||||
|
errors.push({ serverId: server.id, error: String(err) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.send({ ok: true, updatedServers, pushedRules })
|
appendEvent({
|
||||||
|
level: errors.length === 0 ? "info" : "warning",
|
||||||
|
eventType: errors.length === 0 ? "filters.sync.to_router.done" : "filters.sync.to_router.partial",
|
||||||
|
sourceModule: "filters",
|
||||||
|
title: errors.length === 0 ? "Отправка фильтров завершена" : "Отправка фильтров завершена с ошибками",
|
||||||
|
message: `Успешно: ${updatedServers}, ошибок: ${errors.length}, правил: ${pushedRules}`,
|
||||||
|
payload: {
|
||||||
|
updatedServers,
|
||||||
|
pushedRules,
|
||||||
|
errors,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return reply.send({
|
||||||
|
ok: errors.length === 0,
|
||||||
|
updatedServers,
|
||||||
|
pushedRules,
|
||||||
|
errors,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "../services/traffic-collector.js"
|
} from "../services/traffic-collector.js"
|
||||||
import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js"
|
import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js"
|
||||||
import { refreshScheduler } from "../services/scheduler.js"
|
import { refreshScheduler } from "../services/scheduler.js"
|
||||||
|
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||||
|
|
||||||
type SnapshotRow = typeof serverSnapshots.$inferSelect
|
type SnapshotRow = typeof serverSnapshots.$inferSelect
|
||||||
|
|
||||||
@@ -206,15 +207,37 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.post("/traffic/collect-now", async (_req, reply) => {
|
app.post("/traffic/collect-now", async (_req, reply) => {
|
||||||
await collectTrafficOnce()
|
try {
|
||||||
scheduleAlertEngineAfterDataCollectors()
|
await collectTrafficOnce()
|
||||||
const updated = getTrafficSettings()
|
scheduleAlertEngineAfterDataCollectors()
|
||||||
return reply.send({
|
const updated = getTrafficSettings()
|
||||||
ok: true,
|
appendEvent({
|
||||||
lastCollectedAt: updated.lastCollectedAt ?? null,
|
level: "info",
|
||||||
lastDurationMs: updated.lastDurationMs ?? null,
|
eventType: "traffic.collect.manual.ok",
|
||||||
lastError: updated.lastError || null,
|
sourceModule: "traffic",
|
||||||
})
|
title: "Ручной сбор трафика завершен",
|
||||||
|
message: `Длительность: ${updated.lastDurationMs ?? 0} мс`,
|
||||||
|
payload: {
|
||||||
|
lastCollectedAt: updated.lastCollectedAt ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return reply.send({
|
||||||
|
ok: true,
|
||||||
|
lastCollectedAt: updated.lastCollectedAt ?? null,
|
||||||
|
lastDurationMs: updated.lastDurationMs ?? null,
|
||||||
|
lastError: updated.lastError || null,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error)
|
||||||
|
appendEvent({
|
||||||
|
level: "critical",
|
||||||
|
eventType: "traffic.collect.manual.failed",
|
||||||
|
sourceModule: "traffic",
|
||||||
|
title: "Ошибка ручного сбора трафика",
|
||||||
|
message: msg,
|
||||||
|
})
|
||||||
|
return reply.status(500).send({ error: msg })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get("/traffic/servers", async (req, reply) => {
|
app.get("/traffic/servers", async (req, reply) => {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { ingestAlertSignals } from "./signal-ingestor.js"
|
|||||||
import { getLatestSourceFinishedAt, getSourceWatermark, updateSourceWatermark } from "./source-watermark.js"
|
import { getLatestSourceFinishedAt, getSourceWatermark, updateSourceWatermark } from "./source-watermark.js"
|
||||||
import { pickTelegramAlertEmoji } from "./telegram-emoji.js"
|
import { pickTelegramAlertEmoji } from "./telegram-emoji.js"
|
||||||
import type { AlertEngineRunResult, RuleEvalHit } from "./types.js"
|
import type { AlertEngineRunResult, RuleEvalHit } from "./types.js"
|
||||||
|
import { appendEvent } from "../../modules/events/service/events-service.js"
|
||||||
|
|
||||||
const SNAPSHOT_SOURCE_WAIT_MS = 30_000
|
const SNAPSHOT_SOURCE_WAIT_MS = 30_000
|
||||||
const SNAPSHOT_SOURCE_POLL_MS = 20
|
const SNAPSHOT_SOURCE_POLL_MS = 20
|
||||||
@@ -182,6 +183,32 @@ export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
|
|||||||
const outbox = await dispatchPendingOutbox()
|
const outbox = await dispatchPendingOutbox()
|
||||||
errors.push(...outbox.errors)
|
errors.push(...outbox.errors)
|
||||||
|
|
||||||
|
if (standaloneFires > 0 || groupFires > 0) {
|
||||||
|
appendEvent({
|
||||||
|
level: "info",
|
||||||
|
eventType: "alerts.engine.fired",
|
||||||
|
sourceModule: "alerts",
|
||||||
|
title: "Движок алертов обнаружил события",
|
||||||
|
message: `Правила: ${standaloneFires}, группы: ${groupFires}`,
|
||||||
|
payload: {
|
||||||
|
sampledAt,
|
||||||
|
rulesChecked: hasNewSources ? rules.length : 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (errors.length > 0) {
|
||||||
|
appendEvent({
|
||||||
|
level: "warning",
|
||||||
|
eventType: "alerts.engine.errors",
|
||||||
|
sourceModule: "alerts",
|
||||||
|
title: "Ошибки в движке алертов",
|
||||||
|
message: errors[0] ?? "Неизвестная ошибка",
|
||||||
|
payload: {
|
||||||
|
totalErrors: errors.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sampledAt,
|
sampledAt,
|
||||||
rulesChecked: hasNewSources ? rules.length : 0,
|
rulesChecked: hasNewSources ? rules.length : 0,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
isSchedulerJobRunning,
|
isSchedulerJobRunning,
|
||||||
tryBeginSchedulerJob,
|
tryBeginSchedulerJob,
|
||||||
} from "./scheduler-running.js"
|
} from "./scheduler-running.js"
|
||||||
|
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||||
|
|
||||||
export const JOB_KEYS = [
|
export const JOB_KEYS = [
|
||||||
"traffic",
|
"traffic",
|
||||||
@@ -138,6 +139,19 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
|
|||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
result: snapshot ?? null,
|
result: snapshot ?? null,
|
||||||
})
|
})
|
||||||
|
appendEvent({
|
||||||
|
level: "info",
|
||||||
|
eventType: "scheduler.job.ok",
|
||||||
|
sourceModule: "scheduler",
|
||||||
|
title: "Задача планировщика завершена",
|
||||||
|
message: `${jobKey}: выполнено за ${Date.now() - startedAt} мс`,
|
||||||
|
entityType: "job",
|
||||||
|
entityId: jobKey,
|
||||||
|
payload: {
|
||||||
|
startedAt: startedIso,
|
||||||
|
finishedAt: finishedIso,
|
||||||
|
},
|
||||||
|
})
|
||||||
if (
|
if (
|
||||||
jobKey === "traffic" ||
|
jobKey === "traffic" ||
|
||||||
jobKey === "servers_rest_ping" ||
|
jobKey === "servers_rest_ping" ||
|
||||||
@@ -160,6 +174,19 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
|
|||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
result: snapshot ?? null,
|
result: snapshot ?? null,
|
||||||
})
|
})
|
||||||
|
appendEvent({
|
||||||
|
level: "critical",
|
||||||
|
eventType: "scheduler.job.failed",
|
||||||
|
sourceModule: "scheduler",
|
||||||
|
title: "Ошибка задачи планировщика",
|
||||||
|
message: `${jobKey}: ${msg}`,
|
||||||
|
entityType: "job",
|
||||||
|
entityId: jobKey,
|
||||||
|
payload: {
|
||||||
|
startedAt: startedIso,
|
||||||
|
finishedAt: finishedIso,
|
||||||
|
},
|
||||||
|
})
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,13 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"id": "b820bdc8-d184-4615-92e1-40df3872d554",
|
||||||
|
"serverId": "2",
|
||||||
|
"serverName": "Gateway",
|
||||||
|
"filename": "Gateway_2026-05-07_14-41-51.rsc",
|
||||||
|
"sizeBytes": 913056,
|
||||||
|
"createdAt": "2026-05-07T07:41:51.371Z",
|
||||||
|
"kind": "manual"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "7d46ee5b-c829-4d42-af90-847026bafd8a",
|
"id": "7d46ee5b-c829-4d42-af90-847026bafd8a",
|
||||||
"serverId": "6",
|
"serverId": "6",
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-xl border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 gap-y-1 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current [&>svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-border bg-card text-card-foreground",
|
||||||
|
destructive: "border-destructive/40 bg-destructive/10 text-destructive",
|
||||||
|
warning: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400",
|
||||||
|
info: "border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-400",
|
||||||
|
success: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn("col-start-2 font-medium leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn("col-start-2 text-sm [&_p]:leading-relaxed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-action"
|
||||||
|
className={cn("absolute top-3 right-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription, AlertAction, alertVariants }
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { resolvedTheme } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={(resolvedTheme as ToasterProps["theme"]) ?? "system"}
|
||||||
|
className="toaster group"
|
||||||
|
richColors
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast: "group toast group-[.toaster]:border-border group-[.toaster]:bg-card group-[.toaster]:text-card-foreground group-[.toaster]:shadow-lg",
|
||||||
|
description: "group-[.toast]:text-muted-foreground",
|
||||||
|
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||||
|
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
Generated
+11
-15
@@ -17,6 +17,7 @@
|
|||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"shadcn": "^4.5.0",
|
"shadcn": "^4.5.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
@@ -8598,6 +8599,16 @@
|
|||||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sonner": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
@@ -9813,21 +9824,6 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
|
||||||
"version": "16.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
|
|
||||||
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"shadcn": "^4.5.0",
|
"shadcn": "^4.5.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": "./dist/index.js",
|
".": "./dist/index.js",
|
||||||
"./servers": "./dist/servers.js",
|
"./servers": "./dist/servers.js",
|
||||||
"./alerts": "./dist/alerts.js"
|
"./alerts": "./dist/alerts.js",
|
||||||
|
"./events": "./dist/events.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.4.1"
|
"zod": "^4.4.1"
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const eventLevelSchema = z.enum(["critical", "warning", "info"])
|
||||||
|
|
||||||
|
export const eventSourceModuleSchema = z.enum([
|
||||||
|
"scheduler",
|
||||||
|
"backups",
|
||||||
|
"filters",
|
||||||
|
"traffic",
|
||||||
|
"alerts",
|
||||||
|
"system",
|
||||||
|
])
|
||||||
|
|
||||||
|
export const eventItemSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
createdAt: z.string(),
|
||||||
|
level: eventLevelSchema,
|
||||||
|
eventType: z.string().min(1),
|
||||||
|
sourceModule: eventSourceModuleSchema,
|
||||||
|
title: z.string().min(1),
|
||||||
|
message: z.string().min(1),
|
||||||
|
entityType: z.union([z.string(), z.null()]),
|
||||||
|
entityId: z.union([z.string(), z.null()]),
|
||||||
|
payload: z.record(z.string(), z.unknown()).default({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const eventListSchema = z.array(eventItemSchema)
|
||||||
|
|
||||||
|
export const listEventsQuerySchema = z.object({
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
level: eventLevelSchema.optional(),
|
||||||
|
sourceModule: eventSourceModuleSchema.optional(),
|
||||||
|
from: z.string().optional(),
|
||||||
|
to: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const appendEventSchema = z.object({
|
||||||
|
level: eventLevelSchema,
|
||||||
|
eventType: z.string().min(1),
|
||||||
|
sourceModule: eventSourceModuleSchema,
|
||||||
|
title: z.string().min(1),
|
||||||
|
message: z.string().min(1),
|
||||||
|
entityType: z.string().optional(),
|
||||||
|
entityId: z.string().optional(),
|
||||||
|
payload: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
createdAt: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const appendEventsSchema = z.object({
|
||||||
|
events: z.array(appendEventSchema).min(1).max(200),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type EventLevel = z.infer<typeof eventLevelSchema>
|
||||||
|
export type EventSourceModule = z.infer<typeof eventSourceModuleSchema>
|
||||||
|
export type EventItem = z.infer<typeof eventItemSchema>
|
||||||
|
export type ListEventsQuery = z.infer<typeof listEventsQuerySchema>
|
||||||
|
export type AppendEventBody = z.infer<typeof appendEventSchema>
|
||||||
|
export type AppendEventsBody = z.infer<typeof appendEventsSchema>
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from "./servers.js"
|
export * from "./servers.js"
|
||||||
export * from "./alerts.js"
|
export * from "./alerts.js"
|
||||||
|
export * from "./events.js"
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {
|
||||||
|
eventListSchema,
|
||||||
|
listEventsQuerySchema,
|
||||||
|
type EventItem,
|
||||||
|
type ListEventsQuery,
|
||||||
|
} from "@/packages/contracts/src/events"
|
||||||
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
|
||||||
|
type ListEventsResponse = {
|
||||||
|
events: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEvents(
|
||||||
|
baseUrl: string,
|
||||||
|
query: Partial<ListEventsQuery> = {},
|
||||||
|
): Promise<EventItem[]> {
|
||||||
|
const parsedQuery = listEventsQuerySchema.parse(query)
|
||||||
|
const search = new URLSearchParams()
|
||||||
|
search.set("limit", String(parsedQuery.limit))
|
||||||
|
if (parsedQuery.level) search.set("level", parsedQuery.level)
|
||||||
|
if (parsedQuery.sourceModule) search.set("sourceModule", parsedQuery.sourceModule)
|
||||||
|
if (parsedQuery.from) search.set("from", parsedQuery.from)
|
||||||
|
if (parsedQuery.to) search.set("to", parsedQuery.to)
|
||||||
|
const path = `/api/events?${search.toString()}`
|
||||||
|
const payload = await requestJson<ListEventsResponse>(baseUrl, path)
|
||||||
|
return eventListSchema.parse(payload.events)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user