fix(ui): различать снимок историю и неизвестный каталог пользователей

This commit is contained in:
Denozordec committed 2026-10-01 10:18:42 +07:00
1 parent c37498512f
commit 9320c11d58
10 files changed
+111 -52

No files matched your search

+19 -11
View File
@@ -31,8 +31,9 @@ import {
import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
import { cn } from "@/lib/utils"
import type { InterfaceCatalogState } from "@mmapp/contracts/users"
import { can } from "@/lib/auth"
import { listInterfaceCatalog, userApiError } from "@/shared/api/users"
import { readInterfaceCatalogState, userApiError } from "@/shared/api/users"
import { TYPE_VARIANT } from "@/components/data-grids/users-expanded-detail"
import {
bindingDiffKey,
@@ -47,7 +48,6 @@ import {
SECTION_GROUP_DEFS,
type AppUser,
type AppUserForm,
type CatalogIface,
type InterfaceBinding,
type InterfaceType,
type PermLevel,
@@ -164,7 +164,7 @@ function UserSheet({
const catalogSrv = servers.find(s => s.id === catalogServerId)
const catalogKey = JSON.stringify([catalogServerId, catalogSrv?.host])
const [catalogState, setCatalogState] = useState<{ key: string; serverId: string; rows: CatalogIface[]; loading: boolean; error?: string; readAt?: number } | null>(null)
const [catalogState, setCatalogState] = useState<{ key: string; serverId: string; result?: InterfaceCatalogState; loading: boolean; error?: string; readAt?: number } | null>(null)
const [catalogRevision, setCatalogRevision] = useState(0)
const demoCatalog = useMemo(() => {
if (isLive || !catalogSrv) return []
@@ -173,16 +173,16 @@ function UserSheet({
return catalogForServer(catalogServerId, withDraft)
}, [isLive, catalogSrv, catalogServerId, users, user, form.bindings])
const currentCatalog = catalogState?.key === catalogKey ? catalogState : null
const catalog = isLive ? currentCatalog?.rows ?? [] : demoCatalog
const catalog = isLive ? currentCatalog?.result?.interfaces ?? [] : demoCatalog
const catalogReady = !!catalogSrv && (!isLive || !!currentCatalog?.readAt && !currentCatalog.loading && !currentCatalog.error)
useEffect(() => {
if (!open || !isLive || !catalogSrv || !can("mm:users:read")) return
let cancelled = false
const controller = new AbortController()
queueMicrotask(() => { if (!cancelled) setCatalogState(prev => ({ key: catalogKey, serverId: catalogServerId, rows: prev?.key === catalogKey ? prev.rows : [], readAt: prev?.key === catalogKey ? prev.readAt : undefined, loading: true })) })
void listInterfaceCatalog(backendUrl, catalogServerId, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
.then(rows => { if (!cancelled) setCatalogState({ key: catalogKey, serverId: catalogServerId, rows, loading: false, readAt: Date.now() }) })
.catch(error => { if (!cancelled) setCatalogState(prev => ({ key: catalogKey, serverId: catalogServerId, rows: prev?.key === catalogKey ? prev.rows : [], readAt: prev?.key === catalogKey ? prev.readAt : undefined, loading: false, error: userApiError(error) })) })
queueMicrotask(() => { if (!cancelled) setCatalogState(prev => ({ key: catalogKey, serverId: catalogServerId, result: prev?.key === catalogKey ? prev.result : undefined, readAt: prev?.key === catalogKey ? prev.readAt : undefined, loading: true })) })
void readInterfaceCatalogState(backendUrl, catalogServerId, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
.then(result => { if (!cancelled) setCatalogState({ key: catalogKey, serverId: catalogServerId, result, loading: false, readAt: Date.now() }) })
.catch(error => { if (!cancelled) setCatalogState(prev => ({ key: catalogKey, serverId: catalogServerId, result: prev?.key === catalogKey ? prev.result : undefined, readAt: prev?.key === catalogKey ? prev.readAt : undefined, loading: false, error: userApiError(error) })) })
return () => { cancelled = true; controller.abort() }
}, [open, isLive, catalogSrv, catalogServerId, catalogKey, backendUrl, catalogRevision])
@@ -482,11 +482,19 @@ function UserSheet({
{!catalogSrv && <p role="status">Выберите сервер каталога интерфейсов.</p>}
{isLive && currentCatalog?.loading && <p role="status">Чтение каталога интерфейсов…</p>}
{isLive && currentCatalog?.readAt && <p role="status" className="text-xs text-muted-foreground">Каталог получен: {new Date(currentCatalog.readAt).toLocaleTimeString("ru-RU")}</p>}
{isLive && currentCatalog?.result && <div className="text-xs text-muted-foreground" role="status">
<p>{currentCatalog.result.source === "snapshot" ? "Интерфейсы из сохранённого снимка." : currentCatalog.result.source === "traffic-history" ? "Частичный каталог из истории трафика; типы определены по именам." : "Снимок интерфейсов отсутствует."}</p>
<p>{currentCatalog.result.sampledAt ? "Время среза: " + new Date(currentCatalog.result.sampledAt).toLocaleString("ru-RU") : "Время среза неизвестно."} Состояния относятся к этому срезу.</p>
</div>}
{isLive && !!currentCatalog?.result?.failures.length && <div role="alert" aria-label="Частичные сбои каталога" className="text-xs text-destructive">
{currentCatalog.result.failures.map(failure => <p key={failure.resource}>{failure.resource === "interfaces" ? "Интерфейсы" : failure.resource === "wireguard" ? "WireGuard" : "IPsec"}: {failure.error}</p>)}
<Button type="button" variant="outline" onClick={() => { setSelectedPicks([]); setCatalogRevision(n => n + 1) }}>Повторить частичный каталог</Button>
</div>}
{isLive && currentCatalog?.error && <div role="alert" aria-label="Каталог интерфейсов"><p>{currentCatalog.error} Прежние данные доступны для просмотра; выбор приостановлен.</p><Button type="button" variant="outline" onClick={() => { setSelectedPicks([]); setCatalogRevision(n => n + 1) }}>Повторить каталог интерфейсов</Button></div>}
<Frame dense spacing="sm" className="h-64 min-h-0 flex flex-col">
<FramePanel className="flex-1 min-h-0 overflow-y-auto p-1.5">
{catalogReady && catalog.length === 0 && (
<p className="px-2 py-3 text-center text-[11px] text-muted-foreground">Нет интерфейсов в каталоге</p>
<p className="px-2 py-3 text-center text-[11px] text-muted-foreground">{!isLive || currentCatalog?.result?.inventoryComplete ? "Нет интерфейсов в каталоге" : currentCatalog?.result?.source === "traffic-history" ? "В этом срезе нет интерфейсов" : "Список интерфейсов неизвестен"}</p>
)}
<div className="flex flex-col gap-0.5">
{catalog.map((iface) => {
@@ -511,7 +519,7 @@ function UserSheet({
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
<span className="min-w-0 truncate">{iface.name}</span>
<span className="min-w-0 truncate">{iface.type === "ipsec" && iface.name === "ipsec-vpn" ? "Клиенты IPsec" : iface.name}</span>
<Badge variant={TYPE_VARIANT[iface.type]} size="sm">{IFACE_TYPE_LABEL[iface.type]}</Badge>
</ItemTitle>
</ItemContent>
@@ -620,7 +628,7 @@ function UserSheet({
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
{iface.running ? <StatusDot status="online" /> : <StatusDot status="offline" />}
<span title={iface.running === null ? "Running в срезе неизвестен" : iface.running ? "Running в срезе" : "Не running в срезе"}><StatusDot status={iface.running === null ? "unknown" : iface.running ? "online" : "offline"} /></span>
<span className="min-w-0 truncate">{iface.name}</span>
<Badge variant={TYPE_VARIANT[iface.type]} size="sm">
{IFACE_TYPE_LABEL[iface.type]}