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 { cn } from "@/lib/utils"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { toast } from "sonner"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1722,7 +1723,6 @@ function AddRuleSheet({
|
||||
() => !(initialForm?.targets?.length),
|
||||
)
|
||||
const [testTelegramBusy, setTestTelegramBusy] = useState(false)
|
||||
const [testTelegramHint, setTestTelegramHint] = useState<string | null>(null)
|
||||
|
||||
const targets = typeTargets[form.type]
|
||||
const conditions = TYPE_CONDITIONS[form.type]
|
||||
@@ -1817,7 +1817,6 @@ function AddRuleSheet({
|
||||
const handleTestRuleTelegram = async () => {
|
||||
if (!onTestRuleTelegram || !canSave || testTelegramBusy || testTelegramDisabled) return
|
||||
setTestTelegramBusy(true)
|
||||
setTestTelegramHint(null)
|
||||
try {
|
||||
const conditionLine = conditionDisplay || summarizeConditionsUi(form.conditions)
|
||||
await onTestRuleTelegram({
|
||||
@@ -1828,10 +1827,9 @@ function AddRuleSheet({
|
||||
cooldown: form.cooldown,
|
||||
ruleChatId: form.chatId.trim(),
|
||||
})
|
||||
setTestTelegramHint("__ok__")
|
||||
window.setTimeout(() => setTestTelegramHint(null), 5000)
|
||||
toast.success("Тестовое сообщение отправлено в Telegram.")
|
||||
} catch (e) {
|
||||
setTestTelegramHint(e instanceof Error ? e.message : "Не удалось отправить тест")
|
||||
toast.error(e instanceof Error ? e.message : "Не удалось отправить тест")
|
||||
} finally {
|
||||
setTestTelegramBusy(false)
|
||||
}
|
||||
@@ -2327,21 +2325,6 @@ function AddRuleSheet({
|
||||
)}
|
||||
</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 ── */}
|
||||
<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}>
|
||||
|
||||
+10
-22
@@ -13,7 +13,7 @@ import {
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
RefreshCwIcon, PlusIcon, DownloadIcon, Trash2Icon,
|
||||
HardDriveIcon, ClockIcon, ServerIcon, CheckCircleIcon,
|
||||
HardDriveIcon, ClockIcon, ServerIcon,
|
||||
FolderIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -21,6 +21,7 @@ import { useDataSource } from "@/lib/data-source"
|
||||
import { listServers } from "@/shared/api/servers"
|
||||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||
import { createBackupsAsync, deleteBackup, getBackupJob, listBackups, type BackupItem } from "@/shared/api/backups"
|
||||
import { toast } from "sonner"
|
||||
|
||||
// ─── small UI helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -138,11 +139,8 @@ export default function BackupsPage() {
|
||||
})
|
||||
}
|
||||
|
||||
// Settings saved flash
|
||||
const [saved, setSaved] = useState(false)
|
||||
function handleSave() {
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
toast.success("Настройки сохранены")
|
||||
}
|
||||
|
||||
// Manual backup sheet
|
||||
@@ -212,6 +210,7 @@ export default function BackupsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!backupJobId) return
|
||||
toast.info("Бэкап выполняется в фоне...")
|
||||
let cancelled = false
|
||||
const timer = setInterval(() => {
|
||||
void (async () => {
|
||||
@@ -223,6 +222,8 @@ export default function BackupsPage() {
|
||||
setBackupJobId(null)
|
||||
if (job.failures.length > 0) {
|
||||
setOpError(`Часть бэкапов не создалась: ${job.failures.map((f) => `${f.serverId}: ${f.error}`).join("; ")}`)
|
||||
} else {
|
||||
toast.success("Бэкап успешно завершён")
|
||||
}
|
||||
await loadLive()
|
||||
} else if (job.status === "failed") {
|
||||
@@ -244,6 +245,10 @@ export default function BackupsPage() {
|
||||
}
|
||||
}, [backendUrl, backupJobId, loadLive])
|
||||
|
||||
useEffect(() => {
|
||||
if (opError) toast.error(opError)
|
||||
}, [opError])
|
||||
|
||||
// Delete backup
|
||||
async function handleDelete(id: string) {
|
||||
setOpBusy(true)
|
||||
@@ -367,17 +372,6 @@ export default function BackupsPage() {
|
||||
Изменить настройки →
|
||||
</button>
|
||||
</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 */}
|
||||
<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>
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1.5 text-sm text-emerald-500">
|
||||
<CheckCircleIcon className="size-4" />
|
||||
Настройки сохранены
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+15
-12
@@ -5,12 +5,13 @@ import { PageHeader } from "@/components/page-header"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
RefreshCwIcon, DownloadIcon, SearchIcon,
|
||||
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
|
||||
ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
|
||||
XIcon,
|
||||
XIcon, AlertCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
|
||||
@@ -929,10 +930,10 @@ export default function BgpPage() {
|
||||
</div>
|
||||
)}
|
||||
{isLive && liveError && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/8 px-3 py-2">
|
||||
<span className="size-1.5 rounded-full bg-amber-500 shrink-0" />
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Ошибка загрузки: {liveError}</p>
|
||||
</div>
|
||||
<Alert variant="warning" className="py-2">
|
||||
<AlertCircleIcon />
|
||||
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{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">
|
||||
@@ -964,13 +965,15 @@ export default function BgpPage() {
|
||||
|
||||
{/* alert: not-established sessions */}
|
||||
{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">
|
||||
<span className="size-2 rounded-full bg-amber-500 shrink-0" />
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||||
<span className="font-semibold">{notEstab} сессии</span> не в состоянии Established —
|
||||
проверьте {sessions.filter(s => s.state !== "Established").map(s => s.peerIp).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
<Alert variant="warning">
|
||||
<AlertCircleIcon />
|
||||
<AlertTitle>
|
||||
<span className="font-semibold">{notEstab} сессии</span> не в состоянии Established
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Проверьте {sessions.filter(s => s.state !== "Established").map(s => s.peerIp).join(", ")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* tab content */}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { BandwidthChart } from "@/components/dashboard/bandwidth-chart"
|
||||
import {
|
||||
servers as mockServers,
|
||||
pingProbes,
|
||||
systemEvents,
|
||||
dashLatency,
|
||||
traffic,
|
||||
serverFilterRulesets,
|
||||
@@ -26,6 +25,8 @@ import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency"
|
||||
import { listEvents } from "@/shared/api/events"
|
||||
import type { EventItem } from "@/packages/contracts/src/events"
|
||||
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
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")
|
||||
}
|
||||
|
||||
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 {
|
||||
filters: { ruleTotal: number; serversWithRules: number } | null
|
||||
bgp: { prefixSum: number; establishedCount: number } | null
|
||||
@@ -198,6 +212,9 @@ export default function DashboardPage() {
|
||||
const [liveKpi, setLiveKpi] = useState<LiveKpiSnapshot | null>(null)
|
||||
const [probesLoading, setProbesLoading] = useState(false)
|
||||
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(() => {
|
||||
if (!isLive) return mockServers
|
||||
@@ -259,6 +276,25 @@ export default function DashboardPage() {
|
||||
}
|
||||
}, [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(() => {
|
||||
if (!isLive) {
|
||||
queueMicrotask(() => {
|
||||
@@ -277,6 +313,19 @@ export default function DashboardPage() {
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, fetchProbes])
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void fetchRecentEvents(false)
|
||||
})
|
||||
}, [fetchRecentEvents])
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
queueMicrotask(() => { void fetchRecentEvents(true) })
|
||||
}, 20_000)
|
||||
return () => clearInterval(id)
|
||||
}, [fetchRecentEvents])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) return
|
||||
const id = setInterval(() => {
|
||||
@@ -628,24 +677,38 @@ export default function DashboardPage() {
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<p className="text-sm text-muted-foreground">Система и BGP-активность</p>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 px-0">
|
||||
<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 className="mt-0.5">
|
||||
{e.sev === "destructive" && <AlertCircleIcon className="size-4 text-destructive" />}
|
||||
{e.sev === "warning" && <AlertTriangleIcon className="size-4 text-[var(--status-degraded)]" />}
|
||||
{e.sev === "info" && <InfoIcon className="size-4 text-[var(--chart-1)]" />}
|
||||
{e.level === "critical" && <AlertCircleIcon className="size-4 text-destructive" />}
|
||||
{e.level === "warning" && <AlertTriangleIcon className="size-4 text-[var(--status-degraded)]" />}
|
||||
{e.level === "info" && <InfoIcon className="size-4 text-[var(--chart-1)]" />}
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PageHeader } from "@/components/page-header"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -101,7 +102,10 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор трафика ещё выполнялся.</p>
|
||||
) : null}
|
||||
{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}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Сэмплы на момент <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 ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ресурсов уже выполняется.</p>
|
||||
) : 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">
|
||||
Сэмплы на <span className="font-mono tabular-nums">{new Date(u.sampledAt).toLocaleString("ru-RU")}</span>
|
||||
</p>
|
||||
@@ -221,7 +230,12 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
{s.skipped ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка API ещё выполнялась.</p>
|
||||
) : 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">
|
||||
GET <span className="font-mono">/system/identity</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 className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ping уже выполняется.</p>
|
||||
) : 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 className="text-xs text-muted-foreground">Пропущено по интервалу проб: {p.skippedByInterval}</p>
|
||||
) : null}
|
||||
@@ -384,7 +403,12 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
{g.skipped ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p>
|
||||
) : 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">
|
||||
Запись в SQLite на{" "}
|
||||
<span className="font-mono tabular-nums">{new Date(g.sampledAt).toLocaleString("ru-RU")}</span>
|
||||
@@ -470,13 +494,16 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
</div>
|
||||
</dl>
|
||||
{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">
|
||||
{a.errors.map((e, i) => (
|
||||
<p key={i} className="break-words">
|
||||
{e}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<Alert variant="destructive" className="py-2">
|
||||
<AlertCircleIcon />
|
||||
<AlertDescription className="space-y-1 text-xs">
|
||||
{a.errors.map((e, i) => (
|
||||
<p key={i} className="break-words">
|
||||
{e}
|
||||
</p>
|
||||
))}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{a.ruleDiag && a.ruleDiag.length > 0 ? (
|
||||
<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 && (
|
||||
<Card className="border-destructive/50">
|
||||
<CardContent className="flex items-start gap-2 pt-4 pb-4 px-4">
|
||||
<AlertCircleIcon className="size-4 text-destructive shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-destructive">{collectorError}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircleIcon />
|
||||
<AlertTitle>Ошибка загрузки статуса коллекторов</AlertTitle>
|
||||
<AlertDescription>{collectorError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isLive && (
|
||||
|
||||
+81
-32
@@ -29,6 +29,7 @@ import {
|
||||
} from "@/components/ui/sheet"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { toast } from "sonner"
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -201,39 +202,71 @@ function generateCombinedRouterOSConfig(
|
||||
lines.push(`# ${server.name} · ${server.type === "jump-host" ? "JH" : "EN"} · ${server.site} · ${server.host}`)
|
||||
|
||||
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 label = rule.communityName ?? rule.community
|
||||
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 [
|
||||
` ${kw} (bgp-communities.has(\\"${rule.community}\\")) {`,
|
||||
` # ${desc}`,
|
||||
` if (${cond}) {`,
|
||||
...commentLines,
|
||||
` set type blackhole;`,
|
||||
` accept;`,
|
||||
` }`,
|
||||
].join("\n")
|
||||
}
|
||||
if (isRecursiveGatewayRef(rule.gatewayTunnelId)) {
|
||||
const rid = rule.gatewayTunnelId.slice(4)
|
||||
const rr = recList.find(r => r.id === rid)
|
||||
const gw = rr ? gatewayFromRecursiveDst(rr.dstAddress) : rule.gateway
|
||||
return [
|
||||
` ${kw} (bgp-communities.has(\\"${rule.community}\\")) {`,
|
||||
` # ${desc}`,
|
||||
` set gateway ${gw};`,
|
||||
` accept;`,
|
||||
].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")
|
||||
const out = [
|
||||
` if (${cond}) {`,
|
||||
...commentLines,
|
||||
` set gw ${g.gateway};`,
|
||||
]
|
||||
if (g.outIface) out.push(` set out-interface ${g.outIface};`)
|
||||
out.push(` accept;`)
|
||||
out.push(` }`)
|
||||
return out.join("\n")
|
||||
}).join("\n")
|
||||
|
||||
lines.push(`/routing filter rule add \\`)
|
||||
@@ -241,7 +274,7 @@ function generateCombinedRouterOSConfig(
|
||||
lines.push(` comment="RouterLists: ${server.name}" \\`)
|
||||
lines.push(` rule="`)
|
||||
lines.push(branches)
|
||||
lines.push(` }"`)
|
||||
lines.push(` "`)
|
||||
|
||||
if (si < withRules.length - 1) lines.push(``)
|
||||
})
|
||||
@@ -876,9 +909,9 @@ function RuleSheet({
|
||||
</p>
|
||||
<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>
|
||||
{" (bgp-communities.has(\""}
|
||||
{" (bgp-communities includes "}
|
||||
<span className="text-[#79c0ff]">{form.community || "AS:NNN"}</span>
|
||||
{"\")) {\n "}
|
||||
{") {\n "}
|
||||
<span className="text-[#ff7b72]">set type blackhole</span>
|
||||
{";\n accept;\n}"}
|
||||
</div>
|
||||
@@ -999,7 +1032,7 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
|
||||
trimmed.startsWith("set type blackhole")
|
||||
? "text-[#ff7b72] font-semibold" :
|
||||
// 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]" :
|
||||
// rule body: accept / rule close
|
||||
trimmed.startsWith("accept") || trimmed === `}"` || trimmed.startsWith(`rule="`)
|
||||
@@ -1730,15 +1763,31 @@ export default function FiltersPage() {
|
||||
}, [isLive, syncBusy, apiFetch, allServers, selectedServerId, ensureGreTunnels, ensureRecursiveRoutes, fetchRouterCompare])
|
||||
|
||||
const syncToRouter = useCallback(async () => {
|
||||
if (!isLive || syncBusy) return
|
||||
if (!isLive || syncBusy || !selectedServerId) return
|
||||
setSyncBusy("to")
|
||||
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()
|
||||
} catch (err) {
|
||||
toast.error("Не удалось загрузить правила на роутер", { description: String(err) })
|
||||
} finally {
|
||||
setSyncBusy(null)
|
||||
}
|
||||
}, [isLive, syncBusy, apiFetch, fetchRouterCompare])
|
||||
}, [isLive, syncBusy, selectedServerId, apiFetch, fetchRouterCompare])
|
||||
|
||||
const openCreate = () => {
|
||||
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 { requestJson } from "@/shared/api/http-client"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "sonner"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -25,7 +26,6 @@ import {
|
||||
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
|
||||
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
|
||||
DatabaseIcon,
|
||||
AlertCircleIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
// ─── label maps ─────────────────────────────────────────────────────────────
|
||||
@@ -281,7 +281,6 @@ export default function GrePage() {
|
||||
const [dataLoading, setDataLoading] = useState(false)
|
||||
const [dataError, setDataError] = useState<string | null>(null)
|
||||
const [syncJhBusy, setSyncJhBusy] = useState(false)
|
||||
const [syncJhMessage, setSyncJhMessage] = useState<string | null>(null)
|
||||
|
||||
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
|
||||
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
|
||||
@@ -302,7 +301,6 @@ export default function GrePage() {
|
||||
if (!isLive) return
|
||||
setDataLoading(true)
|
||||
setDataError(null)
|
||||
setSyncJhMessage(null)
|
||||
try {
|
||||
const [backendServers, greRes] = await Promise.all([
|
||||
apiFetch<BackendServer[]>("/api/servers"),
|
||||
@@ -353,11 +351,10 @@ export default function GrePage() {
|
||||
if (!isLive || syncJhBusy) return
|
||||
const jh = displayServers.filter((s) => s.type === "jump-host" && s.enabled)
|
||||
if (jh.length === 0) {
|
||||
setSyncJhMessage("Нет включённых Jump Host в списке серверов")
|
||||
toast.info("Нет включённых Jump Host в списке серверов")
|
||||
return
|
||||
}
|
||||
setSyncJhBusy(true)
|
||||
setSyncJhMessage(null)
|
||||
const errors: string[] = []
|
||||
try {
|
||||
for (const s of jh) {
|
||||
@@ -373,17 +370,21 @@ export default function GrePage() {
|
||||
const fresh = await apiFetch<GreTunnelsApiResponse>("/api/filters/gre-tunnels")
|
||||
setLiveTunnels(fresh.tunnels)
|
||||
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 {
|
||||
setSyncJhMessage(`Правила с ${jh.length} JH записаны в БД, список GRE обновлён.`)
|
||||
toast.success(`Правила с ${jh.length} JH записаны в БД, список GRE обновлён.`)
|
||||
}
|
||||
} catch (e) {
|
||||
setSyncJhMessage(e instanceof Error ? e.message : "Ошибка после синхронизации")
|
||||
toast.error(e instanceof Error ? e.message : "Ошибка после синхронизации")
|
||||
} finally {
|
||||
setSyncJhBusy(false)
|
||||
}
|
||||
}, [isLive, syncJhBusy, apiFetch, displayServers])
|
||||
|
||||
useEffect(() => {
|
||||
if (dataError) toast.error(dataError)
|
||||
}, [dataError])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return displayTunnels.filter((t) => {
|
||||
if (tabFilter === "up" && t.status !== "up") return false
|
||||
@@ -413,6 +414,7 @@ export default function GrePage() {
|
||||
function handleCopy(code: string) {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true)
|
||||
toast.success("Команды скопированы")
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
@@ -453,13 +455,6 @@ export default function GrePage() {
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<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 */}
|
||||
<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" />
|
||||
|
||||
@@ -56,12 +56,6 @@ import { Flag } from "@/components/flag"
|
||||
|
||||
// ─── 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) ───────────────────────────────────────
|
||||
|
||||
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" />
|
||||
))}
|
||||
{/* WAN edges */}
|
||||
{wanJhEdges.map((edge, i) => {
|
||||
{wanJhEdges.map((edge) => {
|
||||
const sat = satPos[edge.homeId]?.[edge.wanIdx]
|
||||
const jh = nodes.find(n => n.id === edge.jhId)
|
||||
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" />
|
||||
})}
|
||||
{/* nodes */}
|
||||
@@ -1333,11 +1328,52 @@ export default function NetworkMapPage() {
|
||||
|
||||
const connectedTunnels = selected
|
||||
? mapGreTunnels.filter((t) => {
|
||||
if (t.serverId === selected.id) return true
|
||||
const peer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
|
||||
if (!peer) return false
|
||||
return t.serverId === selected.id || peer.id === selected.id
|
||||
if (peer?.id === selected.id) return true
|
||||
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
|
||||
|
||||
@@ -1619,16 +1655,17 @@ export default function NetworkMapPage() {
|
||||
})}
|
||||
|
||||
{/* ── WAN→JH edges ── */}
|
||||
{visibleWanJhEdges.map((edge, i) => {
|
||||
{visibleWanJhEdges.map((edge) => {
|
||||
const jh = nodeById[edge.jhId]
|
||||
const satPos = effectiveSatPos[edge.homeId]?.[edge.wanIdx]
|
||||
if (!jh || !satPos) return null
|
||||
const edgeKey = `${edge.homeId}-${edge.wanIdx}-${edge.jhId}`
|
||||
const color = WAN_COLORS[edge.wanIdx] ?? "#888"
|
||||
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 isHL = selected?.id === edge.homeId && (selWanIdx === null || selWanIdx === edge.wanIdx)
|
||||
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" }}>
|
||||
<line
|
||||
x1={satPos.x} y1={satPos.y} x2={jh.x} y2={jh.y}
|
||||
@@ -1640,7 +1677,7 @@ export default function NetworkMapPage() {
|
||||
/>
|
||||
{showAnimDots && edge.active && (
|
||||
<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}`} />
|
||||
</circle>
|
||||
)}
|
||||
@@ -2143,10 +2180,11 @@ export default function NetworkMapPage() {
|
||||
<p className="text-[9px] uppercase text-muted-foreground tracking-wider mb-1.5">
|
||||
Подключения к JH
|
||||
</p>
|
||||
{myEdges.map((e, ei) => {
|
||||
{myEdges.map((e) => {
|
||||
const jh = mapServers.find(s => s.id === e.jhId)
|
||||
const edgeKey = `${e.homeId}-${e.wanIdx}-${e.jhId}`
|
||||
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">
|
||||
→ {jh?.name ?? e.jhId}
|
||||
</span>
|
||||
@@ -2186,6 +2224,8 @@ export default function NetworkMapPage() {
|
||||
? `${fromServer.id}|${toServer.id}|${t.id}|${t.localAddress}|${t.remoteAddress}`
|
||||
: `${t.serverId}|${t.id}|${t.localAddress}|${t.remoteAddress}`
|
||||
const baseProbe = greTunnelProbe(t)
|
||||
const groupKey = peer?.id ?? (normalizeGreEndpointAddr(t.remoteAddress) || t.remoteAddress)
|
||||
const isPrimary = grePrimaryByGroupKey.get(groupKey) === t.id
|
||||
const spGre =
|
||||
fromServer && toServer ? speedProbeByTunnelId.get(tunnelPanelKey) : undefined
|
||||
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 className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-mono font-medium">{t.name}</span>
|
||||
<span className="text-[10px] font-medium" style={{ color: ts.stroke }}>
|
||||
{t.status === "up" ? "Up" : t.status === "degraded" ? "Degraded" : "Down"}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"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 className="flex items-center justify-between mb-1">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -5,9 +5,10 @@ import { PageHeader } from "@/components/page-header"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon,
|
||||
CheckIcon, NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon,
|
||||
NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
@@ -664,7 +665,6 @@ function InterfacesTab({
|
||||
const [items, setItems] = useState<OspfItem[]>(initialItems)
|
||||
const [dragging, setDragging] = useState<string | null>(null)
|
||||
const [dragOver, setDragOver] = useState<string | null>(null)
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
const [optimizing, setOptimizing] = useState(false)
|
||||
const [liveOptimalCost, setLiveOptimalCost] = useState<Record<string, number>>({})
|
||||
|
||||
@@ -673,8 +673,6 @@ function InterfacesTab({
|
||||
queueMicrotask(() => setItems(initialItems))
|
||||
}, [initialItems])
|
||||
|
||||
function showToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 2500) }
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const byRouter: Record<string, { routerKey: string; routerLabel: string; areas: Record<string, OspfItem[]> }> = {}
|
||||
items.forEach(item => {
|
||||
@@ -732,7 +730,7 @@ function InterfacesTab({
|
||||
|
||||
async function handleLiveOptimize() {
|
||||
if (!canOptimizeLive) {
|
||||
showToast("Выберите конкретный сервер для оптимизации OSPF")
|
||||
toast.info("Выберите конкретный сервер для оптимизации OSPF")
|
||||
return
|
||||
}
|
||||
const ra = readStoredRouteOptimizerSettings()
|
||||
@@ -750,10 +748,10 @@ function InterfacesTab({
|
||||
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
|
||||
})
|
||||
setLiveOptimalCost(byKey)
|
||||
showToast(`OSPF оптимизация применена: ${data.optimizedCount} интерфейсов`)
|
||||
toast.success(`OSPF оптимизация применена: ${data.optimizedCount} интерфейсов`)
|
||||
onLiveDataRefresh()
|
||||
} catch (err) {
|
||||
showToast(`Ошибка оптимизации OSPF: ${err instanceof Error ? err.message : String(err)}`)
|
||||
toast.error(`Ошибка оптимизации OSPF: ${err instanceof Error ? err.message : String(err)}`)
|
||||
} finally {
|
||||
setOptimizing(false)
|
||||
}
|
||||
@@ -777,7 +775,7 @@ function InterfacesTab({
|
||||
const h = hints[item.key]
|
||||
return h && item.cost !== h.optimalCost ? { ...item, cost: h.optimalCost } : item
|
||||
}))
|
||||
showToast("Costs оптимизированы по рекомендациям оптимизатора")
|
||||
toast.success("Costs оптимизированы по рекомендациям оптимизатора")
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -798,19 +796,12 @@ function InterfacesTab({
|
||||
</Button>
|
||||
)}
|
||||
{!isLive && (
|
||||
<Button size="sm" onClick={() => showToast("OSPF Interface Templates применены")}>
|
||||
<Button size="sm" onClick={() => toast.success("OSPF Interface Templates применены")}>
|
||||
<SaveIcon className="size-4" />Сохранить
|
||||
</Button>
|
||||
)}
|
||||
</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 && (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
OSPF интерфейсы не настроены ни на одном сервере
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { toast } from "sonner"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -871,8 +872,15 @@ export default function SettingsPage() {
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text).catch(() => {})
|
||||
setCopied(text); setTimeout(() => setCopied(null), 1500)
|
||||
toast.success("Скопировано в буфер обмена")
|
||||
}
|
||||
|
||||
const markSaved = useCallback(() => {
|
||||
setSaved(true)
|
||||
toast.success("Настройки сохранены")
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}, [])
|
||||
|
||||
const handleUserSave = (form: UserForm) => {
|
||||
if (!editUser) {
|
||||
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 () => {
|
||||
if (section === "EvoBGP") {
|
||||
if (mode !== "live" || backendStatus !== true) {
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
markSaved()
|
||||
return
|
||||
}
|
||||
setEvoSaveBusy(true)
|
||||
@@ -919,8 +926,7 @@ export default function SettingsPage() {
|
||||
if (evoKeyDraft.trim()) patch.apiKey = evoKeyDraft.trim()
|
||||
await evo.saveSettings(patch)
|
||||
setEvoKeyDraft("")
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
markSaved()
|
||||
} catch (e) {
|
||||
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка сохранения")
|
||||
} finally {
|
||||
@@ -928,8 +934,7 @@ export default function SettingsPage() {
|
||||
}
|
||||
return
|
||||
}
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
markSaved()
|
||||
}, [
|
||||
section,
|
||||
mode,
|
||||
@@ -937,6 +942,7 @@ export default function SettingsPage() {
|
||||
evoBaseDraft,
|
||||
evoEnabledDraft,
|
||||
evoKeyDraft,
|
||||
markSaved,
|
||||
evo.saveSettings,
|
||||
])
|
||||
|
||||
@@ -1212,8 +1218,7 @@ export default function SettingsPage() {
|
||||
try {
|
||||
await evo.saveSettings({ apiKey: null })
|
||||
setEvoKeyDraft("")
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
markSaved()
|
||||
} catch (e) {
|
||||
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка")
|
||||
} finally {
|
||||
|
||||
+19
-15
@@ -5,6 +5,7 @@ import { PageHeader } from "@/components/page-header"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { Sparkline } from "@/components/sparkline"
|
||||
@@ -1001,12 +1002,12 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
|
||||
|
||||
{/* ── Alert banner ──────────────────────────────────────────────────── */}
|
||||
{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">
|
||||
<ServerCrashIcon className="size-4 text-red-500 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-semibold text-red-600 dark:text-red-400 mb-1">
|
||||
{alerts.length} {alerts.length === 1 ? "сервер требует внимания" : "сервера требуют внимания"}
|
||||
</p>
|
||||
<Alert variant="destructive">
|
||||
<ServerCrashIcon />
|
||||
<AlertTitle className="text-xs mb-1">
|
||||
{alerts.length} {alerts.length === 1 ? "сервер требует внимания" : "сервера требуют внимания"}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{alerts.map(r => {
|
||||
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.temp ?? 0) >= 70) issues.push(`${r.temp}°C`)
|
||||
return (
|
||||
<span key={r.serverId} className="inline-flex items-center gap-1 text-[11px] font-mono
|
||||
rounded border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-red-400">
|
||||
<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">
|
||||
<Flag code={s.country} size={10} />
|
||||
{s.name} — {issues.join(", ")}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* ── KPI summary ───────────────────────────────────────────────────── */}
|
||||
@@ -2330,8 +2330,11 @@ export default function UptimePage() {
|
||||
|
||||
{/* ── error ── */}
|
||||
{speedError && (
|
||||
<div className="mx-6 mt-4 text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
||||
{speedError}
|
||||
<div className="mx-6 mt-4">
|
||||
<Alert variant="destructive" className="py-2 text-xs">
|
||||
<AlertCircleIcon />
|
||||
<AlertDescription>{speedError}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2650,9 +2653,10 @@ export default function UptimePage() {
|
||||
|
||||
{opError && (
|
||||
<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">
|
||||
{opError}
|
||||
</div>
|
||||
<Alert variant="destructive" className="py-2 text-xs">
|
||||
<AlertCircleIcon />
|
||||
<AlertDescription>{opError}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+5
-1
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -32,7 +33,10 @@ export default function RootLayout({
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<ThemeProvider>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
{children}
|
||||
<Toaster position="top-right" />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user