From 1999e8160ee6aa20d4fbd05d5d4fbfd9fa3a960e Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 3 Oct 2026 19:33:10 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D1=8F=D1=82=D1=8C=20=D0=B4?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=BE=D0=B2=D0=B5=D1=80=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BE=D0=B1=D1=89=D0=B5=D0=B9=20=D1=81=D0=B2?= =?UTF-8?q?=D0=BE=D0=B4=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/app-sidebar.tsx | 82 +++++++++---------- components/system-monitor-popover.tsx | 109 ++++++++++---------------- docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 10 ++- docs/ux-ui-stage-11b.md | 4 + shared/api/sidebar-counts.test.ts | 10 +++ shared/api/sidebar-counts.ts | 8 ++ tests/e2e/alerts-targets.spec.ts | 4 +- tests/e2e/sidebar-counts.spec.ts | 12 +++ 9 files changed, 124 insertions(+), 117 deletions(-) create mode 100644 shared/api/sidebar-counts.test.ts create mode 100644 shared/api/sidebar-counts.ts create mode 100644 tests/e2e/sidebar-counts.spec.ts diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index e5e84218..2680da78 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -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 & { greTunnels?: number certificates?: number wireguard?: number @@ -36,43 +36,32 @@ export function AppSidebar({ ...props }: React.ComponentProps) { const { mode, backendUrl, prefsHydrated } = useDataSource() const evo = useEvoBGP() const [mounted, setMounted] = React.useState(false) - const [liveCounts, setLiveCounts] = React.useState(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(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) { 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)[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) { 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 ( diff --git a/components/system-monitor-popover.tsx b/components/system-monitor-popover.tsx index 0f14c406..d0795951 100644 --- a/components/system-monitor-popover.tsx +++ b/components/system-monitor-popover.tsx @@ -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 }) { {metric.unit} - + {metric.percent!==null?:null} ) } @@ -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(null) - const [counts, setCounts] = useState(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>)=>{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(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( @@ -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: , 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: , - 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: , 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: , - tone: monitoringCount > 0 ? "success" : "warning", + tone: (monitoringCount??0) > 0 ? "success" : "warning", alert: false, }, ], @@ -209,21 +181,22 @@ export function SystemMonitorPopover() { Система - {spiking ? "Внимание" : "Норма"} + {spiking?"Нет связи":apiOk?"API доступен":"Нет данных"} - +
Монитор MikrotikManager - {new Date().toLocaleTimeString("ru-RU")} + {mode!=="live"?"Демо":data?.receivedAt?new Date(data.receivedAt).toLocaleTimeString("ru-RU"):"Снимок не получен"}
+ {data?.countsError?

{data.countsError}

:null}
{metrics.map((metric, i) => (
{ + 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} +}) diff --git a/shared/api/sidebar-counts.ts b/shared/api/sidebar-counts.ts new file mode 100644 index 00000000..13b7f019 --- /dev/null +++ b/shared/api/sidebar-counts.ts @@ -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(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} diff --git a/tests/e2e/alerts-targets.spec.ts b/tests/e2e/alerts-targets.spec.ts index 97c28fd8..a4ef6601 100644 --- a/tests/e2e/alerts-targets.spec.ts +++ b/tests/e2e/alerts-targets.spec.ts @@ -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([]) }) diff --git a/tests/e2e/sidebar-counts.spec.ts b/tests/e2e/sidebar-counts.spec.ts new file mode 100644 index 00000000..fc6471f6 --- /dev/null +++ b/tests/e2e/sidebar-counts.spec.ts @@ -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(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 доступен")})