fix(ui): сохранять контекст каталога и выбирать клиента IPsec

This commit is contained in:
Denozordec committed 2026-10-01 10:01:09 +07:00
1 parent 4896d8ef6f
commit ecd72d9750
5 files changed
+113 -40

No files matched your search

+55 -38
View File
@@ -31,7 +31,8 @@ import {
import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
import { cn } from "@/lib/utils"
import { listInterfaceCatalog } from "@/shared/api/users"
import { can } from "@/lib/auth"
import { listInterfaceCatalog, userApiError } from "@/shared/api/users"
import { TYPE_VARIANT } from "@/components/data-grids/users-expanded-detail"
import {
bindingDiffKey,
@@ -157,32 +158,33 @@ function UserSheet({
const [form, setForm] = useState<AppUserForm>(() => (user ? toForm(user) : emptyForm(servers)))
const [errors, setErrors] = useState<Partial<Record<keyof AppUserForm, string>>>({})
const [catalogServerId, setCatalogServerId] = useState(servers[0]?.id ?? "")
const [catalog, setCatalog] = useState<CatalogIface[]>([])
const [selectedPicks, setSelectedPicks] = useState<CatalogPick[]>([])
const [expandedWg, setExpandedWg] = useState<string | null>(null)
const [newComment, setNewComment] = useState("")
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 [catalogRevision, setCatalogRevision] = useState(0)
const demoCatalog = useMemo(() => {
if (isLive || !catalogSrv) return []
const occupancy = users.map(u => u.id === user?.id ? { ...u, bindings: form.bindings } : u)
const withDraft = user ? occupancy : [...occupancy, { id: "__draft__", name: "", login: "", email: "", role: "viewer" as const, last: "", avatar: "", active: true, sections: [], servers: [], bindings: form.bindings }]
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 catalogReady = !!catalogSrv && (!isLive || !!currentCatalog?.readAt && !currentCatalog.loading && !currentCatalog.error)
useEffect(() => {
if (!open || !catalogServerId) {
setCatalog([])
return
}
if (!isLive) {
const occupancy = users.map((u) =>
u.id === user?.id ? { ...u, bindings: form.bindings } : u,
)
const withDraft = user
? occupancy
: [...occupancy, { id: "__draft__", name: "", login: "", email: "", role: "viewer" as const, last: "", avatar: "", active: true, sections: [], servers: [], bindings: form.bindings }]
setCatalog(catalogForServer(catalogServerId, withDraft))
return
}
if (!open || !isLive || !catalogSrv || !can("mm:users:read")) return
let cancelled = false
void listInterfaceCatalog(backendUrl, catalogServerId)
.then((ifaces) => { if (!cancelled) setCatalog(ifaces) })
.catch(() => { if (!cancelled) setCatalog([]) })
return () => { cancelled = true }
}, [open, catalogServerId, isLive, backendUrl, users, user, form.bindings])
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) })) })
return () => { cancelled = true; controller.abort() }
}, [open, isLive, catalogSrv, catalogServerId, catalogKey, backendUrl, catalogRevision])
const setField = <K extends keyof AppUserForm>(k: K, v: AppUserForm[K]) => {
setForm((f) => ({ ...f, [k]: v }))
@@ -234,13 +236,22 @@ function UserSheet({
}
const isAdmin = form.role === "admin"
const catalogSrv = servers.find((s) => s.id === catalogServerId)
const validSelectedPicks = catalogReady ? selectedPicks.filter(pick => {
const iface = catalog.find(item => item.name === pick.interfaceName && item.type === pick.type)
if (!iface || iface.peersError || iface.boundUserId && iface.boundUserId !== user?.id) return false
if (iface.type === "wg" || iface.type === "ipsec") {
const peer = iface.peers?.find(item => item.publicKey === pick.peerPublicKey)
return !!peer && (!peer.boundUserId || peer.boundUserId === user?.id)
}
return !pick.peerPublicKey
}) : []
const addSelectedBindings = () => {
if (!catalogSrv || selectedPicks.length === 0) return
if (!catalogReady || !catalogSrv || validSelectedPicks.length === 0 || saving) return
const existing = new Set(form.bindings.map(bindingDiffKey))
const next: InterfaceBinding[] = [...form.bindings]
for (const pick of selectedPicks) {
for (const pick of validSelectedPicks) {
const key = bindingDiffKey({
serverId: catalogSrv.id,
interfaceName: pick.interfaceName,
@@ -452,30 +463,36 @@ function UserSheet({
<StepperContent value={4} className="flex flex-col">
<p className="text-[11px] text-muted-foreground py-2.5 border-b">
Ethernet и GRE — целиком. WireGuard — только пир (public-key).
Ethernet и GRE — целиком. WireGuard — пир (public-key), IPsec — клиент (CN).
</p>
<div className="py-3 flex flex-col gap-3 border-b">
<label className="text-[10px] text-muted-foreground">Сервер</label>
<select
className="h-8 rounded-md border bg-background px-2 text-xs font-mono"
value={catalogServerId}
aria-label="Сервер каталога интерфейсов"
value={catalogSrv ? catalogServerId : ""}
onChange={(e) => { setCatalogServerId(e.target.value); setSelectedPicks([]); setExpandedWg(null) }}
>
{!catalogSrv && <option value="" disabled>Выберите сервер</option>}
{servers.map((s) => (
<option key={s.id} value={s.id}>{s.name} · {s.site}</option>
))}
</select>
<Frame dense spacing="sm">
<FramePanel className="max-h-64 overflow-y-auto p-1.5">
{catalog.length === 0 && (
{!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?.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>
)}
<div className="flex flex-col gap-0.5">
{catalog.map((iface) => {
const tile = IFACE_TILE[iface.type]
const Icon = tile.icon
if (iface.type === "wg") {
if (iface.type === "wg" || iface.type === "ipsec") {
const open = expandedWg === iface.name
const legacyTaken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id)
const legacyMine = alreadyBoundHere.has(`${iface.name}\0`)
@@ -495,7 +512,7 @@ function UserSheet({
<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>
<Badge variant={TYPE_VARIANT.wg} size="sm">WireGuard</Badge>
<Badge variant={TYPE_VARIANT[iface.type]} size="sm">{IFACE_TYPE_LABEL[iface.type]}</Badge>
</ItemTitle>
</ItemContent>
<ItemActions>
@@ -508,21 +525,21 @@ function UserSheet({
{open ? (
<div className="ml-4 flex flex-col gap-0.5 border-l pl-2">
{iface.peersError ? (
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">Не удалось загрузить пиры</p>
<p role="alert" className="px-2 py-1.5 text-xs text-destructive">Не удалось загрузить {iface.type === "ipsec" ? "клиентов IPsec" : "пиры WireGuard"}: {iface.peersError}<Button type="button" variant="outline" onClick={() => { setSelectedPicks([]); setCatalogRevision(n => n + 1) }}>Повторить каталог интерфейсов</Button></p>
) : null}
{(iface.peers ?? []).length === 0 && !iface.peersError ? (
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">Нет пиров на интерфейсе</p>
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">{iface.type === "ipsec" ? "Нет клиентов IPsec" : "Нет пиров на интерфейсе"}</p>
) : null}
{(iface.peers ?? []).map((peer) => {
const pick: CatalogPick = {
interfaceName: iface.name,
peerPublicKey: peer.publicKey,
peerName: peer.name,
type: "wg",
type: iface.type,
}
const taken = Boolean(peer.boundUserId && peer.boundUserId !== user?.id)
const mine = alreadyBoundHere.has(`${iface.name}\0${peer.publicKey}`)
const disabled = taken || mine || legacyTaken
const disabled = !catalogReady || !!iface.peersError || taken || mine || legacyTaken
const selected = selectedPickKeys.has(pickKey(pick))
return (
<Item
@@ -551,7 +568,7 @@ function UserSheet({
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
<span className="min-w-0 truncate">{peer.name || peer.publicKey}</span>
{peer.latestHandshake ? (
<Badge variant="success-light" size="xs">handshake</Badge>
<Badge variant="outline" size="xs" title={peer.latestHandshake}>{iface.type === "ipsec" ? "время активности" : "время handshake"}</Badge>
) : null}
</ItemTitle>
</ItemContent>
@@ -576,7 +593,7 @@ function UserSheet({
const pick: CatalogPick = { interfaceName: iface.name, peerPublicKey: "", type: iface.type }
const taken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id)
const mine = alreadyBoundHere.has(`${iface.name}\0`)
const disabled = taken || mine
const disabled = !catalogReady || taken || mine
const selected = selectedPickKeys.has(pickKey(pick))
return (
<Item
@@ -631,7 +648,7 @@ function UserSheet({
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
/>
<Button size="sm" disabled={selectedPicks.length === 0} onClick={addSelectedBindings}>
<Button size="sm" disabled={!catalogReady || validSelectedPicks.length === 0} onClick={addSelectedBindings}>
<PlusIcon className="size-3.5" />Привязать
</Button>
</div>