fix(ui): сохранять контекст каталога и выбирать клиента IPsec
This commit is contained in:
1 parent
4896d8ef6f
commit
ecd72d9750
5 files changed
+113
-40
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user