fix(ui): проверять права и сохранять достоверность общей сводки
This commit is contained in:
1 parent
b109532f33
commit
1999e8160e
9 files changed
+124
-117
No files matched your search
+37
-45
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
## Актуальная точка продолжения — 2026-10-03
|
||||
|
||||
Этап 11Б в работе; последний проверенный блок 11Б.4.4.3.5; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.4.3.6: исправить права общих sidebar/dashboard readers (источник фонового GRE оказался AppSidebar), затем адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. Processing остаётся без повтора до отдельного сценария подтверждённой остановки отправителя.
|
||||
Этап 11Б в работе; последний проверенный блок 11Б.4.4.3.6; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.4.3.7: применить права backend к каждому запросу dashboard reader и проверить ограниченный JWT; затем адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. Processing recovery требует отдельного сценария остановки отправителя.
|
||||
|
||||
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.4.3.5; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.4.3.6; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@
|
||||
|
||||
|
||||
|
||||
## Блок 11Б.4.4.3.6 — права и достоверность общей сводки (2026-10-03)
|
||||
|
||||
- AppSidebar ждёт ensureAuthConfig, проверяет dashboard:read для counts и filters:read для GRE перед запросом и публикацией. Читатели независимы, bounded/abort/revision/source tag не дают старому ответу заменить новый. Badge скрывается при отсутствии права раздела, неизвестное optional поле не превращается в 0, partial GRE сохраняет последний complete count.
|
||||
- Второй источник фонового sidebar-counts оказался SystemMonitorPopover: также ждёт auth и проверяет dashboard:read, отделяет health от counts. Ошибка summary не выставляет API offline, unknown count отображается как «—»; последний успешный снимок/время сохраняются. Система не заявляет «Норма» по отсутствующим данным, arbitrary count percentage bars удалены; размеры popup ограничены viewport.
|
||||
- Strict counts/GRE API unit, 9 browser scenarios (включая alerts-only JWT без counts/GRE/BGP GET, partial refresh, delayed summary, independent health) и typecheck/lint прошли. Дополнительная регрессия dashboard/links 107 сценариев запущена; на момент коммита ещё выполняется, результат будет записан следующим блоком. Не считать её завершённой заранее.
|
||||
- Backend sidebar-counts всё ещё может возвращать 0 после ошибки своих сетевых count helpers; это отдельное ограничение источника. Текущая UI правка не доказывает достоверность этих upstream counts. Dashboard hook только на dashboard page и ещё требует per-request permission gates. Внешние PG/RouterOS не проверялись.
|
||||
- Следующий точный шаг 11Б.4.4.3.7: применить права backend к каждому запросу dashboard reader и проверить ограниченный JWT; затем адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. Processing recovery требует отдельного сценария остановки отправителя.
|
||||
|
||||
## Блок 11Б.4.4.3.5 — явный повтор очереди в UI (2026-10-03)
|
||||
|
||||
- Unknown item получает доступный по правам/health action и существующий AlertDialog: явное подтверждение проверки чата и риска дубликата. Processing не имеет action. POST передаёт только id/version/risk; строгий helper проверяет pending receipt и version+1 без retry. UI сообщает постановку в очередь, а не доставку.
|
||||
|
||||
@@ -300,3 +300,7 @@ Backend manual unknown retry принят локально: acknowledged risk +
|
||||
### Продолжение 2026-10-03 — 11Б.4.4.3.5
|
||||
|
||||
UI manual unknown retry принят локально: explicit duplicate risk, common lock, strict pending receipt, unknown checkpoint across reload/ABA. API 2, browser 6 + mobile 1, context14, typecheck/lint прошли. Processing recovery и внешняя доставка не приняты. Источник global GRE — AppSidebar, dashboard hook только на dashboard page. Следующий блок 11Б.4.4.3.6: исправить права общих sidebar/dashboard readers (источник фонового GRE оказался AppSidebar), затем адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. Processing остаётся без повтора до отдельного сценария подтверждённой остановки отправителя.
|
||||
|
||||
### Продолжение 2026-10-03 — 11Б.4.4.3.6
|
||||
|
||||
Общие Sidebar/SystemMonitor readers проверяют auth/права, unknown counts не становятся нулями, health независим от summary; API unit/browser9/typecheck/lint прошли. Дополнительная links/dashboard регрессия107 ещё выполняется. Backend count helpers с catch→0 требуют отдельного исправления. Следующий блок 11Б.4.4.3.7: применить права backend к каждому запросу dashboard reader и проверить ограниченный JWT; затем адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. Processing recovery требует отдельного сценария остановки отправителя.
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {test} from "node:test"
|
||||
import {getSidebarCounts,getSidebarGreCount} from "./sidebar-counts"
|
||||
test("sidebar unknown counts and partial GRE never become confirmed zeros",async()=>{
|
||||
const original=globalThis.fetch;try{const signal=new AbortController().signal;globalThis.fetch=async()=>Response.json({servers:0,filterRules:0,uptimeProbes:0,uptimeSpeedProbes:0,monitoringItems:0,recursiveRoutes:0});const counts=await getSidebarCounts("https://backend.test",signal);assert.equal(counts.servers,0);assert.equal(counts.users,undefined)
|
||||
for(const raw of [{},{servers:-1}]){globalThis.fetch=async()=>Response.json(raw);await assert.rejects(getSidebarCounts("https://backend.test",signal),/не подтверждены/)}
|
||||
for(const raw of [{},{tunnels:null},{tunnels:[],failures:[{error:"offline"}]}]){globalThis.fetch=async()=>Response.json(raw);await assert.rejects(getSidebarGreCount("https://backend.test",signal),/не подтверждён/)}
|
||||
globalThis.fetch=async()=>Response.json({tunnels:[],failures:[]});assert.equal(await getSidebarGreCount("https://backend.test",signal),0)
|
||||
}finally{globalThis.fetch=original}
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import {z} from "zod"
|
||||
import {requestJson} from "./http-client"
|
||||
const count=z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
|
||||
const schema=z.object({servers:count,filterRules:count,uptimeProbes:count,uptimeSpeedProbes:count,monitoringItems:count,recursiveRoutes:count,certificates:count.optional(),wireguard:count.optional(),ipsec:count.optional(),users:count.optional(),bgpSessions:count.optional(),vxlan:count.optional(),containers:count.optional()})
|
||||
const greSchema=z.object({tunnels:z.array(z.object({name:z.string(),serverId:z.string()})),failures:z.array(z.unknown()).optional()})
|
||||
async function read(base:string,path:string,signal:AbortSignal){signal.throwIfAborted();const raw=await requestJson<unknown>(base,path,{signal,cache:"no-store"});signal.throwIfAborted();return raw}
|
||||
export async function getSidebarCounts(base:string,signal:AbortSignal){const result=schema.safeParse(await read(base,"/api/sidebar-counts",signal));if(!result.success)throw Error("Счётчики меню не подтверждены");return result.data}
|
||||
export async function getSidebarGreCount(base:string,signal:AbortSignal){const result=greSchema.safeParse(await read(base,"/api/filters/gre-tunnels",signal));if(!result.success||result.data.failures?.length)throw Error("Полный каталог GRE не подтверждён");return result.data.tunnels.length}
|
||||
@@ -24,8 +24,8 @@ test("Alerts catalogs: initial partial GRE remains unknown",async({page})=>{awai
|
||||
test("Alerts catalogs: alerts read permission does not grant network catalog access",async({page})=>{
|
||||
const token="header."+Buffer.from(JSON.stringify({sub:"fixture",apps:["mm"],permissions:["mm:alerts:read"],exp:Math.floor(Date.now()/1000)+3600})).toString("base64url")+".fixture"
|
||||
await page.addInitScript(value=>localStorage.setItem("mmapp_token",value),token)
|
||||
const calls:string[]=[];await setup(page,(r,path)=>{if(["/api/bgp/sessions","/api/filters/gre-tunnels"].includes(path))calls.push(path);return r.fulfill({json:[]})},true)
|
||||
const calls:string[]=[];await setup(page,(r,path)=>{if(["/api/bgp/sessions","/api/filters/gre-tunnels","/api/sidebar-counts"].includes(path))calls.push(path);return r.fulfill({json:[]})},true)
|
||||
await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible()
|
||||
for(const label of ["BGP","Серверы","GRE"])await expect(page.getByRole("region",{name:"Каталог "+label,exact:true}).getByRole("alert")).toHaveText("Нет доступа к каталогу")
|
||||
const baseline=[...calls];for(const label of ["BGP","Серверы","GRE"])await page.getByRole("button",{name:"Обновить каталог "+label,exact:true}).click();expect(calls).toEqual(baseline);expect(calls).not.toContain("/api/bgp/sessions")
|
||||
const baseline=[...calls];for(const label of ["BGP","Серверы","GRE"])await page.getByRole("button",{name:"Обновить каталог "+label,exact:true}).click();expect(calls).toEqual(baseline);expect(calls).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import {test,expect,type Page} from "@playwright/test"
|
||||
import {emptyAlerts,emptyOutbox} from "./fixtures/alerts"
|
||||
const counts={servers:4,filterRules:0,uptimeProbes:0,uptimeSpeedProbes:0,monitoringItems:0,recursiveRoutes:0}
|
||||
async function setup(page:Page,reply:(path:string)=>{status?:number;json:unknown}){await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill(path==="/api/auth/config"?{json:{required:false}}:path==="/api/alerts"?{json:emptyAlerts}:path==="/api/alerts/outbox"?{json:emptyOutbox}:reply(path))});await page.goto("/alerts")}
|
||||
const badge=(page:Page,name:string)=>page.getByRole("navigation",{name:"Основная навигация"}).getByRole("link",{name:new RegExp(name)}).locator("span").last()
|
||||
test("Sidebar missing optional counts remain unknown; confirmed zero is displayed",async({page})=>{await setup(page,path=>({json:path==="/api/sidebar-counts"?counts:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]}));await expect(badge(page,"Серверы")).toHaveText("4");await expect(badge(page,"GRE-туннели")).toHaveText("0");await expect(badge(page,"Пользователи")).toHaveText("Пользователи")})
|
||||
test("Sidebar partial GRE refresh retains last complete count, other counts update independently",async({page})=>{await page.clock.install();let partial=false;await setup(page,path=>({json:path==="/api/sidebar-counts"?{...counts,servers:partial?5:4}:path==="/api/filters/gre-tunnels"?{tunnels:partial?[]:[{name:"g",serverId:"1"}],failures:partial?[{error:"offline"}]:[]}:[]}));await expect(badge(page,"GRE-туннели")).toHaveText("1");partial=true;await page.clock.fastForward(120001);await expect(badge(page,"Серверы")).toHaveText("5");await expect(badge(page,"GRE-туннели")).toHaveText("1")})
|
||||
test("Sidebar GRE does not wait for unavailable summary reader",async({page})=>{await setup(page,path=>path==="/api/sidebar-counts"?{status:503,json:{error:"counts unavailable"}}:{json:path==="/api/filters/gre-tunnels"?{tunnels:[{name:"g",serverId:"1"}],failures:[]}:[]});await expect(badge(page,"GRE-туннели")).toHaveText("1");await expect(badge(page,"Серверы")).toHaveText("Серверы")})
|
||||
|
||||
test("Sidebar GRE arrives while summary request is pending",async({page})=>{let started=false,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve);await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",async r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/sidebar-counts"){started=true;await gate;try{await r.fulfill({json:counts})}catch{};return}return r.fulfill({json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?emptyAlerts:path==="/api/alerts/outbox"?emptyOutbox:path==="/api/filters/gre-tunnels"?{tunnels:[{name:"g",serverId:"1"}],failures:[]}:[]})});try{await page.goto("/alerts");await expect.poll(()=>started).toBe(true);await expect(badge(page,"GRE-туннели")).toHaveText("1");await expect(badge(page,"Серверы")).toHaveText("Серверы");release();await expect(badge(page,"Серверы")).toHaveText("4")}finally{release()}})
|
||||
|
||||
test("System monitor summary failure preserves API health and last confirmed counts",async({page})=>{await page.clock.install();let fail=false;await setup(page,path=>path==="/api/sidebar-counts"&&fail?{status:503,json:{error:"summary unavailable"}}:{json:path==="/api/sidebar-counts"?counts:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]});const button=page.getByRole("button",{name:"Монитор системы"});await expect(button).toContainText("API доступен");await button.click();const popover=page.locator('[data-slot="popover-content"]');await expect(popover.getByText("4шт.",{exact:true})).toBeVisible();fail=true;await page.clock.fastForward(30001);await expect(popover.getByRole("status")).toContainText("Сводка не обновлена");await expect(popover.getByText("4шт.",{exact:true})).toBeVisible();await expect(button).toContainText("API доступен")})
|
||||
Reference in new issue
Block a user