fix(ui): проверять права и сохранять достоверность общей сводки

This commit is contained in:
Denozordec committed 2026-10-03 19:33:10 +07:00
1 parent b109532f33
commit 1999e8160e
9 files changed
+124 -117

No files matched your search

+37 -45
View File
@@ -12,16 +12,16 @@ import {
} from "@/components/ui/sidebar"
import { navigationGroups } from "@/lib/navigation"
import { useDataSource } from "@/lib/data-source"
import { can } from "@/lib/auth"
import { can,ensureAuthConfig } from "@/lib/auth"
import { useEvoBGP } from "@/lib/evobgp-context"
import { requestJson } from "@/shared/api/http-client"
import {getSidebarCounts,getSidebarGreCount} from "@/shared/api/sidebar-counts"
import {
formatSidebarBadgeCount,
mockSidebarBadgesByUrl,
type SidebarCountsDto,
} from "@/lib/sidebar-badges"
type LiveSidebarCounts = SidebarCountsDto & {
type LiveSidebarCounts = Partial<SidebarCountsDto> & {
greTunnels?: number
certificates?: number
wireguard?: number
@@ -36,43 +36,32 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { mode, backendUrl, prefsHydrated } = useDataSource()
const evo = useEvoBGP()
const [mounted, setMounted] = React.useState(false)
const [liveCounts, setLiveCounts] = React.useState<LiveSidebarCounts | null>(null)
const [snapshot, setSnapshot] = React.useState<{source:string;counts:LiveSidebarCounts}|null>(null)
const source=JSON.stringify([mode,backendUrl]),liveCounts=snapshot?.source===source?snapshot.counts:null
const countsAllowed=can("mm:dashboard:read"),greAllowed=can("mm:filters:read")
const mockBadges = React.useMemo(() => mockSidebarBadgesByUrl(), [])
React.useEffect(() => {
queueMicrotask(() => {
setMounted(true)
})
let cancelled=false
void ensureAuthConfig().then(()=>{if(!cancelled)setMounted(true)})
return()=>{cancelled=true}
}, [])
React.useEffect(() => {
if (!prefsHydrated || mode !== "live") {
if (mode !== "live") setLiveCounts(null)
return
if(!mounted||!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=AbortSignal.any([controller.signal,AbortSignal.timeout(30_000)])
const publish=(counts:LiveSidebarCounts)=>{if(cancelled||request!==revision)return;setSnapshot(previous=>({source,counts:{...(previous?.source===source?previous.counts:{}),...counts}}))}
await Promise.allSettled([
can("mm:dashboard:read")?getSidebarCounts(backendUrl,signal).then(counts=>{if(can("mm:dashboard:read"))publish(counts)}):Promise.resolve(),
can("mm:filters:read")?getSidebarGreCount(backendUrl,signal).then(greTunnels=>{if(can("mm:filters:read"))publish({greTunnels})}):Promise.resolve(),
])
}
let cancelled = false
const load = async () => {
try {
const [cJson, gJson] = await Promise.all([
requestJson<SidebarCountsDto>(backendUrl, "/api/sidebar-counts"),
requestJson<{ tunnels?: unknown[] }>(backendUrl, "/api/filters/gre-tunnels").catch(
() => ({ tunnels: [] as unknown[] }),
),
])
if (cancelled) return
setLiveCounts({ ...cJson, greTunnels: (gJson.tunnels ?? []).length })
} catch {
if (!cancelled) setLiveCounts(null)
}
}
void load()
const id = window.setInterval(load, 120_000)
return () => {
cancelled = true
window.clearInterval(id)
}
}, [mode, backendUrl, prefsHydrated])
void load();const timer=window.setInterval(load,120_000)
return()=>{cancelled=true;revision++;controller?.abort();window.clearInterval(timer)}
},[mounted,mode,backendUrl,prefsHydrated,source,countsAllowed,greAllowed])
const navGroups = React.useMemo((): NavGroup[] => {
function badgeFor(url: string): string | undefined {
@@ -86,19 +75,22 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return formatSidebarBadgeCount(evo.snapshot.asns.length)
}
if (!liveCounts) return undefined
if (!liveCounts||(!countsAllowed&&url!=="/gre")) return undefined
const permission=({"/servers":"mm:servers:read","/users":"mm:users:read","/filters":"mm:filters:read","/uptime":"mm:uptime:read","/gre":"mm:filters:read","/certificates":"mm:certificates:read","/wireguard":"mm:network:read","/ipsec":"mm:network:read","/bgp":"mm:bgp:read","/vxlan":"mm:network:read","/containers":"mm:dashboard:read"} as Record<string,string>)[url]
if(permission&&!can(permission))return undefined
const format=(value:number|undefined)=>value===undefined?undefined:formatSidebarBadgeCount(value)
if (url === "/servers") return formatSidebarBadgeCount(liveCounts.servers)
if (url === "/users") return formatSidebarBadgeCount(liveCounts.users ?? 0)
if (url === "/filters") return formatSidebarBadgeCount(liveCounts.filterRules)
if (url === "/uptime") return formatSidebarBadgeCount(liveCounts.monitoringItems)
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
if (url === "/certificates") return formatSidebarBadgeCount(liveCounts.certificates ?? 0)
if (url === "/wireguard") return formatSidebarBadgeCount(liveCounts.wireguard ?? 0)
if (url === "/ipsec") return formatSidebarBadgeCount(liveCounts.ipsec ?? 0)
if (url === "/bgp") return formatSidebarBadgeCount(liveCounts.bgpSessions ?? 0)
if (url === "/vxlan") return formatSidebarBadgeCount(liveCounts.vxlan ?? 0)
if (url === "/containers") return formatSidebarBadgeCount(liveCounts.containers ?? 0)
if (url === "/servers") return format(liveCounts.servers)
if (url === "/users") return format(liveCounts.users )
if (url === "/filters") return format(liveCounts.filterRules)
if (url === "/uptime") return format(liveCounts.monitoringItems)
if (url === "/gre") return format(liveCounts.greTunnels )
if (url === "/certificates") return format(liveCounts.certificates )
if (url === "/wireguard") return format(liveCounts.wireguard )
if (url === "/ipsec") return format(liveCounts.ipsec )
if (url === "/bgp") return format(liveCounts.bgpSessions )
if (url === "/vxlan") return format(liveCounts.vxlan )
if (url === "/containers") return format(liveCounts.containers )
return undefined
}
@@ -112,7 +104,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
badge: mounted ? badgeFor(it.url) : undefined,
})),
}))
}, [mounted, mode, liveCounts, mockBadges, evo.settingsLoaded, evo.enabled, evo.snapshot])
}, [mounted, mode, liveCounts, countsAllowed, mockBadges, evo.settingsLoaded, evo.enabled, evo.snapshot])
return (
<Sidebar collapsible="icon" {...props}>
+41 -68
View File
@@ -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