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:
Denozordec
2026-05-07 20:49:35 +07:00
parent 84ecd4f061
commit 11ad94f67d
33 changed files with 12350 additions and 252 deletions
+70 -7
View File
@@ -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>