Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m24s
Docker images / frontend-image (push) Successful in 2m6s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 7s
Replaced direct fetch calls with requestJson and requestBlob utility functions across multiple components for improved consistency and error handling. This change enhances the maintainability of the codebase by centralizing API request logic and ensuring uniform handling of authentication and response parsing.
250 lines
7.8 KiB
TypeScript
250 lines
7.8 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useMemo, useState, type ReactNode } from "react"
|
|
import { Activity, Filter, HeartPulse, Server } from "lucide-react"
|
|
|
|
import { Badge } from "@/components/reui/badge"
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
|
import { cn } from "@/lib/utils"
|
|
import { useDataSource } from "@/lib/data-source"
|
|
import { filters, pingProbes, servers } from "@/lib/data"
|
|
import type { SidebarCountsDto } from "@/lib/sidebar-badges"
|
|
import { resolveApiUrl, requestJson } from "@/shared/api/http-client"
|
|
|
|
type MonitorMetric = {
|
|
id: string
|
|
label: string
|
|
value: string
|
|
unit: string
|
|
percent: number
|
|
icon: ReactNode
|
|
tone: "success" | "warning" | "destructive" | "info"
|
|
alert: boolean
|
|
}
|
|
|
|
type HealthDto = {
|
|
status?: string
|
|
}
|
|
|
|
function toneColor(tone: MonitorMetric["tone"]) {
|
|
switch (tone) {
|
|
case "success":
|
|
return "var(--color-success)"
|
|
case "warning":
|
|
return "var(--color-warning)"
|
|
case "destructive":
|
|
return "var(--color-destructive)"
|
|
default:
|
|
return "var(--color-info)"
|
|
}
|
|
}
|
|
|
|
function MetricBar({ percent, color }: { percent: number; color: string }) {
|
|
return (
|
|
<div className="bg-muted h-1 w-full overflow-hidden rounded-full">
|
|
<div
|
|
className="h-full rounded-full"
|
|
style={{
|
|
width: `${Math.min(100, Math.max(0, percent))}%`,
|
|
backgroundColor: color,
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function MetricCell({ metric }: { metric: MonitorMetric }) {
|
|
const color = toneColor(metric.tone)
|
|
return (
|
|
<div className="flex flex-col gap-2 p-3">
|
|
<div className="flex items-center justify-between gap-1">
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<div
|
|
className="flex size-5 shrink-0 items-center justify-center rounded-md"
|
|
style={{ backgroundColor: `${color}18`, color }}
|
|
>
|
|
{metric.icon}
|
|
</div>
|
|
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
|
|
</div>
|
|
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
|
|
{metric.value}
|
|
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
|
|
</span>
|
|
</div>
|
|
<MetricBar percent={metric.percent} color={color} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Live system monitor popover — app-shell-7. @see https://reui.io/preview/base/app-shell-7 */
|
|
export function SystemMonitorPopover() {
|
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
|
const [healthOk, setHealthOk] = useState<boolean | null>(null)
|
|
const [counts, setCounts] = useState<SidebarCountsDto | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!prefsHydrated) return
|
|
if (mode !== "live") {
|
|
setHealthOk(true)
|
|
setCounts({
|
|
servers: servers.length,
|
|
filterRules: filters.length,
|
|
uptimeProbes: pingProbes.length,
|
|
uptimeSpeedProbes: 0,
|
|
monitoringItems: pingProbes.length,
|
|
recursiveRoutes: 0,
|
|
})
|
|
return
|
|
}
|
|
|
|
let cancelled = false
|
|
const load = async () => {
|
|
try {
|
|
const [hRes, counts] = await Promise.all([
|
|
fetch(resolveApiUrl(backendUrl, "/health"), { signal: AbortSignal.timeout(3000) }),
|
|
requestJson<SidebarCountsDto>(backendUrl, "/api/sidebar-counts"),
|
|
])
|
|
if (cancelled) return
|
|
if (hRes.ok) {
|
|
const json = (await hRes.json()) as HealthDto
|
|
setHealthOk(json.status === "ok")
|
|
} else {
|
|
setHealthOk(false)
|
|
}
|
|
setCounts(counts)
|
|
} catch {
|
|
if (!cancelled) {
|
|
setHealthOk(false)
|
|
setCounts(null)
|
|
}
|
|
}
|
|
}
|
|
void load()
|
|
const id = window.setInterval(load, 30_000)
|
|
return () => {
|
|
cancelled = true
|
|
window.clearInterval(id)
|
|
}
|
|
}, [mode, backendUrl, prefsHydrated])
|
|
|
|
const serversCount = counts?.servers ?? 0
|
|
const filtersCount = counts?.filterRules ?? 0
|
|
const monitoringCount = counts?.monitoringItems ?? 0
|
|
const apiOk = healthOk === true
|
|
|
|
const metrics = useMemo<MonitorMetric[]>(
|
|
() => [
|
|
{
|
|
id: "api",
|
|
label: "API",
|
|
value: healthOk == null ? "…" : apiOk ? "OK" : "—",
|
|
unit: "",
|
|
percent: apiOk ? 100 : 20,
|
|
icon: <Activity className="size-3" aria-hidden />,
|
|
tone: apiOk ? "success" : "destructive",
|
|
alert: healthOk === false,
|
|
},
|
|
{
|
|
id: "servers",
|
|
label: "Серверы",
|
|
value: String(serversCount),
|
|
unit: "шт.",
|
|
percent: Math.min(100, Math.max(12, serversCount * 8)),
|
|
icon: <Server className="size-3" aria-hidden />,
|
|
tone: serversCount > 0 ? "success" : "warning",
|
|
alert: false,
|
|
},
|
|
{
|
|
id: "filters",
|
|
label: "Фильтры",
|
|
value: String(filtersCount),
|
|
unit: "шт.",
|
|
percent: Math.min(100, Math.max(12, filtersCount * 5)),
|
|
icon: <Filter className="size-3" aria-hidden />,
|
|
tone: "info",
|
|
alert: false,
|
|
},
|
|
{
|
|
id: "uptime",
|
|
label: "Мониторинг",
|
|
value: String(monitoringCount),
|
|
unit: "шт.",
|
|
percent: Math.min(100, Math.max(12, monitoringCount * 8)),
|
|
icon: <HeartPulse className="size-3" aria-hidden />,
|
|
tone: monitoringCount > 0 ? "success" : "warning",
|
|
alert: false,
|
|
},
|
|
],
|
|
[apiOk, filtersCount, healthOk, monitoringCount, serversCount],
|
|
)
|
|
|
|
const spiking = metrics.some((m) => m.alert)
|
|
|
|
return (
|
|
<Popover>
|
|
<PopoverTrigger
|
|
render={
|
|
<button
|
|
type="button"
|
|
aria-label="Монитор системы"
|
|
className={cn(
|
|
"relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none",
|
|
"border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
|
|
)}
|
|
/>
|
|
}
|
|
>
|
|
<span className="relative flex size-3.5 items-center justify-center">
|
|
<Activity
|
|
aria-hidden
|
|
className={cn(
|
|
"size-3.5 transition-colors",
|
|
spiking ? "text-destructive" : "text-muted-foreground",
|
|
)}
|
|
/>
|
|
{spiking ? (
|
|
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
|
|
) : null}
|
|
</span>
|
|
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
|
|
<Badge
|
|
variant={spiking ? "destructive-light" : "success-light"}
|
|
size="xs"
|
|
className="h-4 px-1.5 text-[10px]"
|
|
>
|
|
{spiking ? "Внимание" : "Норма"}
|
|
</Badge>
|
|
</PopoverTrigger>
|
|
|
|
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
|
|
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
|
<span className="text-foreground text-xs font-medium">Монитор MikrotikManager</span>
|
|
<span className="text-muted-foreground text-[11px] tabular-nums">
|
|
{new Date().toLocaleTimeString("ru-RU")}
|
|
</span>
|
|
</div>
|
|
<div className="grid grid-cols-2">
|
|
{metrics.map((metric, i) => (
|
|
<div
|
|
key={metric.id}
|
|
className={cn(
|
|
i % 2 === 1 && "border-border border-l",
|
|
i >= 2 && "border-border border-t",
|
|
)}
|
|
>
|
|
<MetricCell metric={metric} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
|
|
Источник:{" "}
|
|
<span className="text-foreground font-medium">
|
|
{mode === "live" ? "API" : "мок"}
|
|
</span>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|