Docker images / prepare-release (push) Successful in 4s
Docker images / backend-image (push) Failing after 2m36s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 55s
Docker images / publish-release (push) Skipped
Подключить App Switcher, NavUser, OpsPanel и AlertDialog вместо Card-shell. Co-authored-by: Cursor <cursoragent@cursor.com>
253 lines
7.7 KiB
TypeScript
253 lines
7.7 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"
|
|
|
|
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 } = useDataSource()
|
|
const [healthOk, setHealthOk] = useState<boolean | null>(null)
|
|
const [counts, setCounts] = useState<SidebarCountsDto | null>(null)
|
|
|
|
useEffect(() => {
|
|
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 () => {
|
|
const base = backendUrl.replace(/\/$/, "")
|
|
try {
|
|
const [hRes, cRes] = await Promise.all([
|
|
fetch(`${base}/health`),
|
|
fetch(`${base}/api/sidebar-counts`),
|
|
])
|
|
if (cancelled) return
|
|
if (hRes.ok) {
|
|
const json = (await hRes.json()) as HealthDto
|
|
setHealthOk(json.status === "ok")
|
|
} else {
|
|
setHealthOk(false)
|
|
}
|
|
if (cRes.ok) {
|
|
setCounts((await cRes.json()) as SidebarCountsDto)
|
|
} else {
|
|
setCounts(null)
|
|
}
|
|
} catch {
|
|
if (!cancelled) {
|
|
setHealthOk(false)
|
|
setCounts(null)
|
|
}
|
|
}
|
|
}
|
|
void load()
|
|
const id = window.setInterval(load, 30_000)
|
|
return () => {
|
|
cancelled = true
|
|
window.clearInterval(id)
|
|
}
|
|
}, [mode, backendUrl])
|
|
|
|
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>
|
|
)
|
|
}
|