feat(traffic, users): enhance interface and peer management features
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 40s
Docker images / publish-release (push) Successful in 10s
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 40s
Docker images / publish-release (push) Successful in 10s
Added support for managing peer information in interface bindings, including new fields for peerPublicKey and peerName in the BoundIfaceTraffic interface. Updated the database schema to include these fields in user_interface_bindings and traffic_samples tables. Enhanced the UI components to display peer details alongside interface names, improving user experience and clarity in the traffic management system. Updated relevant functions and services to handle peer-specific logic, ensuring robust integration across the application.
This commit is contained in:
@@ -12,7 +12,7 @@ import {
|
||||
type AppUser,
|
||||
type InterfaceType,
|
||||
} from "@/lib/users"
|
||||
import { CableIcon, NetworkIcon, ShieldIcon } from "lucide-react"
|
||||
import { CableIcon, KeyRoundIcon, NetworkIcon, ShieldIcon } from "lucide-react"
|
||||
|
||||
const TYPE_VARIANT: Record<InterfaceType, "outline" | "info-light" | "success-light" | "secondary"> = {
|
||||
ether: "outline",
|
||||
@@ -87,23 +87,29 @@ function UsersExpandedDetail({
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
|
||||
{items.map((b) => {
|
||||
const meta = TYPE_ICON[b.interfaceType]
|
||||
const Icon = meta.icon
|
||||
const Icon = b.interfaceType === "wg" && b.peerPublicKey ? KeyRoundIcon : meta.icon
|
||||
const iconClass = b.interfaceType === "wg" && b.peerPublicKey ? "text-success" : meta.className
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
className="flex items-start gap-2 rounded-md border px-3 py-2.5"
|
||||
>
|
||||
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", meta.className)}>
|
||||
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", iconClass)}>
|
||||
<Icon />
|
||||
</IconTile>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-mono font-medium leading-tight truncate">
|
||||
{b.interfaceName}
|
||||
{b.interfaceType === "wg" && (b.peerName || b.peerPublicKey)
|
||||
? `${b.peerName || "peer"} · ${b.interfaceName}`
|
||||
: b.interfaceName}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1 mt-0.5">
|
||||
<Badge variant={TYPE_VARIANT[b.interfaceType]} size="sm">
|
||||
{IFACE_TYPE_LABEL[b.interfaceType]}
|
||||
</Badge>
|
||||
{b.interfaceType === "wg" && !(b.peerPublicKey ?? "") ? (
|
||||
<Badge variant="warning-light" size="sm">весь интерфейс</Badge>
|
||||
) : null}
|
||||
{b.comment ? (
|
||||
<span className="text-[10px] text-muted-foreground leading-tight truncate">
|
||||
{b.comment}
|
||||
|
||||
+229
-59
@@ -19,12 +19,23 @@ import {
|
||||
StepperTrigger,
|
||||
} from "@/components/reui/stepper"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { listInterfaceCatalog } from "@/shared/api/users"
|
||||
import { TYPE_VARIANT } from "@/components/data-grids/users-expanded-detail"
|
||||
import {
|
||||
bindingDiffKey,
|
||||
bindingTitle,
|
||||
catalogForServer,
|
||||
defaultSections,
|
||||
defaultServers,
|
||||
@@ -43,10 +54,28 @@ import {
|
||||
type UserServerOption,
|
||||
} from "@/lib/users"
|
||||
import {
|
||||
LayoutDashboardIcon, EyeIcon, PlusIcon, ServerIcon, ShieldIcon,
|
||||
TrashIcon, WrenchIcon,
|
||||
CableIcon, ChevronDownIcon, EyeIcon, KeyRoundIcon, LayoutDashboardIcon,
|
||||
NetworkIcon, PlusIcon, ServerIcon, ShieldIcon, TrashIcon, WrenchIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
const IFACE_TILE: Record<InterfaceType, { icon: typeof CableIcon; className: string }> = {
|
||||
ether: { icon: CableIcon, className: "text-muted-foreground" },
|
||||
gre: { icon: NetworkIcon, className: "text-info" },
|
||||
wg: { icon: ShieldIcon, className: "text-success" },
|
||||
other: { icon: CableIcon, className: "text-muted-foreground" },
|
||||
}
|
||||
|
||||
type CatalogPick = {
|
||||
interfaceName: string
|
||||
peerPublicKey: string
|
||||
peerName?: string
|
||||
type: InterfaceType
|
||||
}
|
||||
|
||||
function pickKey(p: CatalogPick): string {
|
||||
return `${p.interfaceName}\0${p.peerPublicKey}`
|
||||
}
|
||||
|
||||
const SECTION_GROUP_ICONS: Record<string, ReactNode> = {
|
||||
"Обзор": <LayoutDashboardIcon className="size-3" />,
|
||||
"Данные": <EyeIcon className="size-3" />,
|
||||
@@ -124,7 +153,8 @@ function UserSheet({
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof AppUserForm, string>>>({})
|
||||
const [catalogServerId, setCatalogServerId] = useState(servers[0]?.id ?? "")
|
||||
const [catalog, setCatalog] = useState<CatalogIface[]>([])
|
||||
const [selectedNames, setSelectedNames] = useState<string[]>([])
|
||||
const [selectedPicks, setSelectedPicks] = useState<CatalogPick[]>([])
|
||||
const [expandedWg, setExpandedWg] = useState<string | null>(null)
|
||||
const [newComment, setNewComment] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
@@ -133,7 +163,8 @@ function UserSheet({
|
||||
setSheetStep(1)
|
||||
setErrors({})
|
||||
setCatalogServerId(servers[0]?.id ?? "")
|
||||
setSelectedNames([])
|
||||
setSelectedPicks([])
|
||||
setExpandedWg(null)
|
||||
setNewComment("")
|
||||
}, [open, user, servers])
|
||||
|
||||
@@ -212,29 +243,32 @@ function UserSheet({
|
||||
const catalogSrv = servers.find((s) => s.id === catalogServerId)
|
||||
|
||||
const addSelectedBindings = () => {
|
||||
if (!catalogSrv || selectedNames.length === 0) return
|
||||
const existing = new Set(form.bindings.map((b) => `${b.serverId}::${b.interfaceName}`))
|
||||
if (!catalogSrv || selectedPicks.length === 0) return
|
||||
const existing = new Set(form.bindings.map(bindingDiffKey))
|
||||
const next: InterfaceBinding[] = [...form.bindings]
|
||||
for (const name of selectedNames) {
|
||||
const key = `${catalogSrv.id}::${name}`
|
||||
for (const pick of selectedPicks) {
|
||||
const key = bindingDiffKey({
|
||||
serverId: catalogSrv.id,
|
||||
interfaceName: pick.interfaceName,
|
||||
peerPublicKey: pick.peerPublicKey,
|
||||
})
|
||||
if (existing.has(key)) continue
|
||||
const iface = catalog.find((c) => c.name === name)
|
||||
if (!iface) continue
|
||||
if (iface.boundUserId && iface.boundUserId !== user?.id) continue
|
||||
next.push({
|
||||
id: `pending-${catalogSrv.id}-${name}`,
|
||||
id: `pending-${catalogSrv.id}-${pick.interfaceName}-${pick.peerPublicKey || "iface"}`,
|
||||
userId: user?.id ?? "",
|
||||
serverId: catalogSrv.id,
|
||||
serverName: catalogSrv.name,
|
||||
serverSite: catalogSrv.site,
|
||||
serverCountry: catalogSrv.country,
|
||||
interfaceName: name,
|
||||
interfaceType: iface.type,
|
||||
interfaceName: pick.interfaceName,
|
||||
interfaceType: pick.type,
|
||||
peerPublicKey: pick.peerPublicKey || undefined,
|
||||
peerName: pick.peerName,
|
||||
comment: newComment.trim(),
|
||||
})
|
||||
}
|
||||
setForm((f) => ({ ...f, bindings: next }))
|
||||
setSelectedNames([])
|
||||
setSelectedPicks([])
|
||||
setNewComment("")
|
||||
}
|
||||
|
||||
@@ -242,15 +276,26 @@ function UserSheet({
|
||||
setForm((f) => ({ ...f, bindings: f.bindings.filter((b) => b.id !== id) }))
|
||||
}
|
||||
|
||||
const toggleName = (name: string) => {
|
||||
setSelectedNames((prev) => prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name])
|
||||
const togglePick = (pick: CatalogPick) => {
|
||||
setSelectedPicks((prev) => {
|
||||
const key = pickKey(pick)
|
||||
return prev.some((p) => pickKey(p) === key)
|
||||
? prev.filter((p) => pickKey(p) !== key)
|
||||
: [...prev, pick]
|
||||
})
|
||||
}
|
||||
|
||||
const alreadyBoundHere = useMemo(
|
||||
() => new Set(form.bindings.filter((b) => b.serverId === catalogServerId).map((b) => b.interfaceName)),
|
||||
() => new Set(
|
||||
form.bindings
|
||||
.filter((b) => b.serverId === catalogServerId)
|
||||
.map((b) => `${b.interfaceName}\0${b.peerPublicKey ?? ""}`),
|
||||
),
|
||||
[form.bindings, catalogServerId],
|
||||
)
|
||||
|
||||
const selectedPickKeys = useMemo(() => new Set(selectedPicks.map(pickKey)), [selectedPicks])
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
||||
@@ -411,7 +456,7 @@ 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).
|
||||
</p>
|
||||
|
||||
<div className="py-3 flex flex-col gap-3 border-b">
|
||||
@@ -419,56 +464,178 @@ function UserSheet({
|
||||
<select
|
||||
className="h-8 rounded-md border bg-background px-2 text-xs font-mono"
|
||||
value={catalogServerId}
|
||||
onChange={(e) => { setCatalogServerId(e.target.value); setSelectedNames([]) }}
|
||||
onChange={(e) => { setCatalogServerId(e.target.value); setSelectedPicks([]); setExpandedWg(null) }}
|
||||
>
|
||||
{servers.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name} · {s.site}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex flex-col gap-1 rounded-md border border-input bg-background px-2 py-1.5 max-h-48 overflow-y-auto">
|
||||
{catalog.length === 0 && (
|
||||
<p className="text-[11px] text-muted-foreground py-1">Нет интерфейсов в каталоге</p>
|
||||
)}
|
||||
{catalog.map((iface) => {
|
||||
const taken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id)
|
||||
const mine = alreadyBoundHere.has(iface.name)
|
||||
const disabled = taken || mine
|
||||
return (
|
||||
<label
|
||||
key={iface.name}
|
||||
className={cn(
|
||||
"flex items-center gap-2 py-0.5 text-xs",
|
||||
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={disabled}
|
||||
checked={selectedNames.includes(iface.name)}
|
||||
onChange={() => toggleName(iface.name)}
|
||||
className="rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="font-mono">{iface.name}</span>
|
||||
<Badge variant={TYPE_VARIANT[iface.type as InterfaceType]} size="sm">
|
||||
{IFACE_TYPE_LABEL[iface.type as InterfaceType]}
|
||||
</Badge>
|
||||
{taken && (
|
||||
<span className="text-[10px] text-muted-foreground ml-auto">{iface.boundUserLogin}</span>
|
||||
)}
|
||||
{mine && !taken && (
|
||||
<span className="text-[10px] text-muted-foreground ml-auto">уже привязан</span>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Frame dense spacing="sm">
|
||||
<FramePanel className="max-h-64 overflow-y-auto p-1.5">
|
||||
{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") {
|
||||
const open = expandedWg === iface.name
|
||||
const legacyTaken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id)
|
||||
const legacyMine = alreadyBoundHere.has(`${iface.name}\0`)
|
||||
return (
|
||||
<div key={iface.name} className="flex flex-col gap-0.5">
|
||||
<Item
|
||||
size="xs"
|
||||
variant={open ? "muted" : "default"}
|
||||
render={<button type="button" onClick={() => setExpandedWg(open ? null : iface.name)} />}
|
||||
className="h-11 min-h-11 flex-nowrap rounded-md py-0"
|
||||
>
|
||||
<ItemMedia>
|
||||
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", tile.className)}>
|
||||
<Icon />
|
||||
</IconTile>
|
||||
</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>
|
||||
<Badge variant={TYPE_VARIANT.wg} size="sm">WireGuard</Badge>
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
{legacyMine ? (
|
||||
<Badge variant="warning-light" size="xs">весь интерфейс</Badge>
|
||||
) : null}
|
||||
<ChevronDownIcon className={cn("size-3.5 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
</ItemActions>
|
||||
</Item>
|
||||
{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>
|
||||
) : null}
|
||||
{(iface.peers ?? []).length === 0 && !iface.peersError ? (
|
||||
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">Нет пиров на интерфейсе</p>
|
||||
) : null}
|
||||
{(iface.peers ?? []).map((peer) => {
|
||||
const pick: CatalogPick = {
|
||||
interfaceName: iface.name,
|
||||
peerPublicKey: peer.publicKey,
|
||||
peerName: peer.name,
|
||||
type: "wg",
|
||||
}
|
||||
const taken = Boolean(peer.boundUserId && peer.boundUserId !== user?.id)
|
||||
const mine = alreadyBoundHere.has(`${iface.name}\0${peer.publicKey}`)
|
||||
const disabled = taken || mine || legacyTaken
|
||||
const selected = selectedPickKeys.has(pickKey(pick))
|
||||
return (
|
||||
<Item
|
||||
key={peer.publicKey}
|
||||
size="xs"
|
||||
variant={selected ? "muted" : "default"}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => togglePick(pick)}
|
||||
/>
|
||||
}
|
||||
className={cn(
|
||||
"h-11 min-h-11 flex-nowrap rounded-md py-0",
|
||||
selected && "ring-1 ring-border",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<ItemMedia>
|
||||
<IconTile variant="elevated" className="size-10.5 shrink-0 text-success">
|
||||
<KeyRoundIcon />
|
||||
</IconTile>
|
||||
</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">{peer.name || peer.publicKey}</span>
|
||||
{peer.latestHandshake ? (
|
||||
<Badge variant="success-light" size="xs">handshake</Badge>
|
||||
) : null}
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
{taken ? (
|
||||
<span className="max-w-28 truncate text-[10px] text-muted-foreground">{peer.boundUserLogin}</span>
|
||||
) : mine ? (
|
||||
<span className="text-[10px] text-muted-foreground">уже привязан</span>
|
||||
) : selected ? (
|
||||
<Badge variant="secondary" size="xs">Выбран</Badge>
|
||||
) : null}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 selected = selectedPickKeys.has(pickKey(pick))
|
||||
return (
|
||||
<Item
|
||||
key={iface.name}
|
||||
size="xs"
|
||||
variant={selected ? "muted" : "default"}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => togglePick(pick)}
|
||||
/>
|
||||
}
|
||||
className={cn(
|
||||
"h-11 min-h-11 flex-nowrap rounded-md py-0",
|
||||
selected && "ring-1 ring-border",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<ItemMedia>
|
||||
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", tile.className)}>
|
||||
<Icon />
|
||||
</IconTile>
|
||||
</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 className="min-w-0 truncate">{iface.name}</span>
|
||||
<Badge variant={TYPE_VARIANT[iface.type]} size="sm">
|
||||
{IFACE_TYPE_LABEL[iface.type]}
|
||||
</Badge>
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
{taken ? (
|
||||
<span className="max-w-28 truncate text-[10px] text-muted-foreground">{iface.boundUserLogin}</span>
|
||||
) : mine ? (
|
||||
<span className="text-[10px] text-muted-foreground">уже привязан</span>
|
||||
) : selected ? (
|
||||
<Badge variant="secondary" size="xs">Выбран</Badge>
|
||||
) : null}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
placeholder="Комментарий (необязательно)"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
/>
|
||||
<Button size="sm" disabled={selectedNames.length === 0} onClick={addSelectedBindings}>
|
||||
<Button size="sm" disabled={selectedPicks.length === 0} onClick={addSelectedBindings}>
|
||||
<PlusIcon className="size-3.5" />Привязать
|
||||
</Button>
|
||||
</div>
|
||||
@@ -480,8 +647,11 @@ function UserSheet({
|
||||
{form.bindings.map((b) => (
|
||||
<div key={b.id} className="flex items-center gap-2 py-1">
|
||||
<Flag code={b.serverCountry} size={12} />
|
||||
<span className="font-mono text-xs truncate">{b.interfaceName}</span>
|
||||
<span className="font-mono text-xs truncate">{bindingTitle(b)}</span>
|
||||
<Badge variant={TYPE_VARIANT[b.interfaceType]} size="sm">{IFACE_TYPE_LABEL[b.interfaceType]}</Badge>
|
||||
{b.interfaceType === "wg" && !(b.peerPublicKey ?? "") ? (
|
||||
<Badge variant="warning-light" size="sm">весь интерфейс</Badge>
|
||||
) : null}
|
||||
<span className="text-[10px] text-muted-foreground truncate">{b.serverName}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
Reference in New Issue
Block a user