fix(ui): различать снимок историю и неизвестный каталог пользователей
This commit is contained in:
1 parent
c37498512f
commit
9320c11d58
10 files changed
+111
-52
No files matched your search
@@ -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]}
|
||||
|
||||
Reference in new issue
Block a user