fix(ui): проверять права и сохранять достоверность общей сводки
This commit is contained in:
1 parent
b109532f33
commit
1999e8160e
9 files changed
+124
-117
No files matched your search
@@ -9,22 +9,23 @@ 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"
|
||||
import { resolveApiUrl } from "@/shared/api/http-client"
|
||||
|
||||
import {can,ensureAuthConfig} from "@/lib/auth"
|
||||
import {getSidebarCounts} from "@/shared/api/sidebar-counts"
|
||||
|
||||
type MonitorMetric = {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
unit: string
|
||||
percent: number
|
||||
percent: number|null
|
||||
icon: ReactNode
|
||||
tone: "success" | "warning" | "destructive" | "info"
|
||||
alert: boolean
|
||||
}
|
||||
|
||||
type HealthDto = {
|
||||
status?: string
|
||||
}
|
||||
|
||||
|
||||
function toneColor(tone: MonitorMetric["tone"]) {
|
||||
switch (tone) {
|
||||
@@ -72,7 +73,7 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
|
||||
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<MetricBar percent={metric.percent} color={color} />
|
||||
{metric.percent!==null?<MetricBar percent={metric.percent} color={color} />:null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -80,57 +81,28 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
|
||||
/** 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
|
||||
const source=JSON.stringify([mode,backendUrl])
|
||||
const [snapshot,setSnapshot]=useState<{source:string;healthOk:boolean|null;counts:SidebarCountsDto|null;receivedAt:string|null;countsError:string|null}|null>(null)
|
||||
const data=snapshot?.source===source?snapshot:null,healthOk=mode!=="live"?true:data?.healthOk??null
|
||||
const counts=mode!=="live"?{servers:servers.length,filterRules:filters.length,monitoringItems:pingProbes.length}:can("mm:dashboard:read")?data?.counts:null
|
||||
useEffect(()=>{
|
||||
if(!prefsHydrated||mode!=="live")return
|
||||
let cancelled=false,revision=0,controller:AbortController|null=null
|
||||
const load=async()=>{
|
||||
controller?.abort();controller=new AbortController();const request=++revision,signal=controller.signal
|
||||
const publish=(patch:Partial<NonNullable<typeof snapshot>>)=>{if(cancelled||request!==revision)return;setSnapshot(previous=>({...((previous?.source===source?previous:null)??{source,healthOk:null,counts:null,receivedAt:null,countsError:null}),...patch}))}
|
||||
await Promise.allSettled([
|
||||
(async()=>{try{const response=await fetch(resolveApiUrl(backendUrl,"/health"),{signal:AbortSignal.any([signal,AbortSignal.timeout(3000)]),cache:"no-store"});const raw=await response.json();publish({healthOk:response.ok&&raw?.status==="ok"})}catch{publish({healthOk:false})}})(),
|
||||
(async()=>{await ensureAuthConfig();if(cancelled||request!==revision)return;if(!can("mm:dashboard:read")){publish({counts:null,countsError:"Нет доступа к сводке"});return}try{const result=await getSidebarCounts(backendUrl,AbortSignal.any([signal,AbortSignal.timeout(30_000)]));if(can("mm:dashboard:read"))publish({counts:result,receivedAt:new Date().toISOString(),countsError:null})}catch{publish({countsError:"Сводка не обновлена; показан последний успешный снимок, если он есть."})}})(),
|
||||
])
|
||||
}
|
||||
void load();const timer=window.setInterval(load,30_000)
|
||||
return()=>{cancelled=true;revision++;controller?.abort();window.clearInterval(timer)}
|
||||
},[mode,backendUrl,prefsHydrated,source])
|
||||
|
||||
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 serversCount=mode!=="live"||can("mm:servers:read")?counts?.servers:undefined
|
||||
const filtersCount=mode!=="live"||can("mm:filters:read")?counts?.filterRules:undefined
|
||||
const monitoringCount=mode!=="live"||can("mm:uptime:read")?counts?.monitoringItems:undefined
|
||||
const apiOk = healthOk === true
|
||||
|
||||
const metrics = useMemo<MonitorMetric[]>(
|
||||
@@ -140,7 +112,7 @@ export function SystemMonitorPopover() {
|
||||
label: "API",
|
||||
value: healthOk == null ? "…" : apiOk ? "OK" : "—",
|
||||
unit: "",
|
||||
percent: apiOk ? 100 : 20,
|
||||
percent: healthOk===null?null:apiOk ? 100 : 0,
|
||||
icon: <Activity className="size-3" aria-hidden />,
|
||||
tone: apiOk ? "success" : "destructive",
|
||||
alert: healthOk === false,
|
||||
@@ -148,19 +120,19 @@ export function SystemMonitorPopover() {
|
||||
{
|
||||
id: "servers",
|
||||
label: "Серверы",
|
||||
value: String(serversCount),
|
||||
value: serversCount===undefined?"—":String(serversCount),
|
||||
unit: "шт.",
|
||||
percent: Math.min(100, Math.max(12, serversCount * 8)),
|
||||
percent: null,
|
||||
icon: <Server className="size-3" aria-hidden />,
|
||||
tone: serversCount > 0 ? "success" : "warning",
|
||||
tone: (serversCount??0) > 0 ? "success" : "warning",
|
||||
alert: false,
|
||||
},
|
||||
{
|
||||
id: "filters",
|
||||
label: "Фильтры",
|
||||
value: String(filtersCount),
|
||||
value: filtersCount===undefined?"—":String(filtersCount),
|
||||
unit: "шт.",
|
||||
percent: Math.min(100, Math.max(12, filtersCount * 5)),
|
||||
percent: null,
|
||||
icon: <Filter className="size-3" aria-hidden />,
|
||||
tone: "info",
|
||||
alert: false,
|
||||
@@ -168,11 +140,11 @@ export function SystemMonitorPopover() {
|
||||
{
|
||||
id: "uptime",
|
||||
label: "Мониторинг",
|
||||
value: String(monitoringCount),
|
||||
value: monitoringCount===undefined?"—":String(monitoringCount),
|
||||
unit: "шт.",
|
||||
percent: Math.min(100, Math.max(12, monitoringCount * 8)),
|
||||
percent: null,
|
||||
icon: <HeartPulse className="size-3" aria-hidden />,
|
||||
tone: monitoringCount > 0 ? "success" : "warning",
|
||||
tone: (monitoringCount??0) > 0 ? "success" : "warning",
|
||||
alert: false,
|
||||
},
|
||||
],
|
||||
@@ -209,21 +181,22 @@ export function SystemMonitorPopover() {
|
||||
</span>
|
||||
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
|
||||
<Badge
|
||||
variant={spiking ? "destructive-light" : "success-light"}
|
||||
variant={spiking ? "destructive-light" : apiOk?"success-light":"outline"}
|
||||
size="xs"
|
||||
className="h-4 px-1.5 text-[10px]"
|
||||
>
|
||||
{spiking ? "Внимание" : "Норма"}
|
||||
{spiking?"Нет связи":apiOk?"API доступен":"Нет данных"}
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
|
||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] 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")}
|
||||
{mode!=="live"?"Демо":data?.receivedAt?new Date(data.receivedAt).toLocaleTimeString("ru-RU"):"Снимок не получен"}
|
||||
</span>
|
||||
</div>
|
||||
{data?.countsError?<p role="status" className="px-3 py-2 text-xs text-muted-foreground">{data.countsError}</p>:null}
|
||||
<div className="grid grid-cols-2">
|
||||
{metrics.map((metric, i) => (
|
||||
<div
|
||||
|
||||
Reference in new issue
Block a user