feat(traffic, users): enhance interface and peer management features
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:
@@ -53,6 +53,8 @@ interface BoundIfaceTraffic {
|
|||||||
userName: string
|
userName: string
|
||||||
interfaceName: string
|
interfaceName: string
|
||||||
interfaceType: InterfaceType
|
interfaceType: InterfaceType
|
||||||
|
peerPublicKey?: string
|
||||||
|
peerName?: string
|
||||||
comment: string
|
comment: string
|
||||||
serverId: string
|
serverId: string
|
||||||
serverName: string
|
serverName: string
|
||||||
@@ -141,6 +143,11 @@ function hashSeed(s: string): number {
|
|||||||
return Math.abs(h)
|
return Math.abs(h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function boundIfaceLabel(c: Pick<BoundIfaceTraffic, "interfaceName" | "interfaceType" | "peerName">): string {
|
||||||
|
if (c.interfaceType === "wg" && c.peerName) return `${c.peerName} · ${c.interfaceName}`
|
||||||
|
return c.interfaceName
|
||||||
|
}
|
||||||
|
|
||||||
function mockBoundFromUsers(): BoundIfaceTraffic[] {
|
function mockBoundFromUsers(): BoundIfaceTraffic[] {
|
||||||
return INIT_USERS.flatMap((u) =>
|
return INIT_USERS.flatMap((u) =>
|
||||||
u.bindings.map((b) => {
|
u.bindings.map((b) => {
|
||||||
@@ -149,13 +156,15 @@ function mockBoundFromUsers(): BoundIfaceTraffic[] {
|
|||||||
const rxNow = offline ? 0 : 12 + (seed % 140)
|
const rxNow = offline ? 0 : 12 + (seed % 140)
|
||||||
const txNow = offline ? 0 : 8 + (seed % 110)
|
const txNow = offline ? 0 : 8 + (seed % 110)
|
||||||
return {
|
return {
|
||||||
id: `${b.userId}:${b.serverId}:${b.interfaceName}`,
|
id: `${b.userId}:${b.serverId}:${b.interfaceName}:${b.peerPublicKey ?? "_iface"}`,
|
||||||
bindingId: b.id,
|
bindingId: b.id,
|
||||||
userId: u.id,
|
userId: u.id,
|
||||||
userLogin: u.login,
|
userLogin: u.login,
|
||||||
userName: u.name,
|
userName: u.name,
|
||||||
interfaceName: b.interfaceName,
|
interfaceName: b.interfaceName,
|
||||||
interfaceType: b.interfaceType,
|
interfaceType: b.interfaceType,
|
||||||
|
peerPublicKey: b.peerPublicKey,
|
||||||
|
peerName: b.peerName,
|
||||||
comment: b.comment,
|
comment: b.comment,
|
||||||
serverId: b.serverId,
|
serverId: b.serverId,
|
||||||
serverName: b.serverName,
|
serverName: b.serverName,
|
||||||
@@ -397,7 +406,7 @@ function IfaceCard({ c, selected, onClick }: { c: BoundIfaceTraffic; selected: b
|
|||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<StatusDot status={c.status} />
|
<StatusDot status={c.status} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-mono font-medium truncate">{c.interfaceName}</p>
|
<p className="text-xs font-mono font-medium truncate">{boundIfaceLabel(c)}</p>
|
||||||
<p className="text-[10px] text-muted-foreground truncate">{c.userLogin}</p>
|
<p className="text-[10px] text-muted-foreground truncate">{c.userLogin}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -435,7 +444,7 @@ function IfaceRow({ c, showServer = false }: { c: BoundIfaceTraffic; showServer?
|
|||||||
<StatusDot status={c.status} />
|
<StatusDot status={c.status} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-xs font-mono font-semibold">{c.interfaceName}</span>
|
<span className="text-xs font-mono font-semibold">{boundIfaceLabel(c)}</span>
|
||||||
<Badge variant={TYPE_VARIANT[c.interfaceType]} size="sm">{IFACE_TYPE_LABEL[c.interfaceType]}</Badge>
|
<Badge variant={TYPE_VARIANT[c.interfaceType]} size="sm">{IFACE_TYPE_LABEL[c.interfaceType]}</Badge>
|
||||||
{showServer && (
|
{showServer && (
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
<span className="inline-flex items-center gap-1 text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||||
@@ -652,7 +661,7 @@ function IfaceDetail({ sel, range, setRange }: { sel: BoundIfaceTraffic; range:
|
|||||||
<DetailHeader range={range} setRange={setRange}>
|
<DetailHeader range={range} setRange={setRange}>
|
||||||
<StatusDot status={sel.status} />
|
<StatusDot status={sel.status} />
|
||||||
<div className="leading-tight min-w-0">
|
<div className="leading-tight min-w-0">
|
||||||
<h2 className="text-base font-mono font-semibold">{sel.interfaceName}</h2>
|
<h2 className="text-base font-mono font-semibold">{boundIfaceLabel(sel)}</h2>
|
||||||
<p className="text-xs text-muted-foreground truncate">{sel.comment || sel.userLogin}</p>
|
<p className="text-xs text-muted-foreground truncate">{sel.comment || sel.userLogin}</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={TYPE_VARIANT[sel.interfaceType]} size="sm">{IFACE_TYPE_LABEL[sel.interfaceType]}</Badge>
|
<Badge variant={TYPE_VARIANT[sel.interfaceType]} size="sm">{IFACE_TYPE_LABEL[sel.interfaceType]}</Badge>
|
||||||
@@ -897,6 +906,7 @@ export default function TrafficPage() {
|
|||||||
return [...activeBoundIfaces]
|
return [...activeBoundIfaces]
|
||||||
.filter(c => !q
|
.filter(c => !q
|
||||||
|| c.interfaceName.toLowerCase().includes(q)
|
|| c.interfaceName.toLowerCase().includes(q)
|
||||||
|
|| (c.peerName ?? "").toLowerCase().includes(q)
|
||||||
|| c.comment.toLowerCase().includes(q)
|
|| c.comment.toLowerCase().includes(q)
|
||||||
|| c.userLogin.toLowerCase().includes(q))
|
|| c.userLogin.toLowerCase().includes(q))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useDataSource } from "@/lib/data-source"
|
|||||||
import {
|
import {
|
||||||
ALL_SECTIONS,
|
ALL_SECTIONS,
|
||||||
INIT_USERS,
|
INIT_USERS,
|
||||||
|
bindingDiffKey,
|
||||||
userInitials,
|
userInitials,
|
||||||
type AppUser,
|
type AppUser,
|
||||||
type AppUserForm,
|
type AppUserForm,
|
||||||
@@ -106,19 +107,21 @@ export default function UsersPage() {
|
|||||||
const activeCount = users.filter((u) => u.active).length
|
const activeCount = users.filter((u) => u.active).length
|
||||||
|
|
||||||
const applyBindingsDiff = async (userId: string, next: AppUserForm["bindings"], prev: AppUser["bindings"]) => {
|
const applyBindingsDiff = async (userId: string, next: AppUserForm["bindings"], prev: AppUser["bindings"]) => {
|
||||||
const nextKeys = new Set(next.map((b) => `${b.serverId}::${b.interfaceName}`))
|
const nextKeys = new Set(next.map(bindingDiffKey))
|
||||||
const prevKeys = new Map(prev.map((b) => [`${b.serverId}::${b.interfaceName}`, b] as const))
|
const prevKeys = new Map(prev.map((b) => [bindingDiffKey(b), b] as const))
|
||||||
for (const b of prev) {
|
for (const b of prev) {
|
||||||
if (!nextKeys.has(`${b.serverId}::${b.interfaceName}`)) {
|
if (!nextKeys.has(bindingDiffKey(b))) {
|
||||||
await deleteUserBinding(backendUrl, userId, b.id)
|
await deleteUserBinding(backendUrl, userId, b.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const b of next) {
|
for (const b of next) {
|
||||||
if (!prevKeys.has(`${b.serverId}::${b.interfaceName}`)) {
|
if (!prevKeys.has(bindingDiffKey(b))) {
|
||||||
await createUserBinding(backendUrl, userId, {
|
await createUserBinding(backendUrl, userId, {
|
||||||
serverId: Number(b.serverId),
|
serverId: Number(b.serverId),
|
||||||
interfaceName: b.interfaceName,
|
interfaceName: b.interfaceName,
|
||||||
interfaceType: b.interfaceType,
|
interfaceType: b.interfaceType,
|
||||||
|
peerPublicKey: b.peerPublicKey,
|
||||||
|
peerName: b.peerName,
|
||||||
comment: b.comment,
|
comment: b.comment,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-1
@@ -106,6 +106,7 @@ CREATE TABLE IF NOT EXISTS traffic_samples (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
server_id INTEGER NOT NULL,
|
server_id INTEGER NOT NULL,
|
||||||
interface_name TEXT NOT NULL,
|
interface_name TEXT NOT NULL,
|
||||||
|
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||||
sampled_at TEXT NOT NULL,
|
sampled_at TEXT NOT NULL,
|
||||||
rx_bytes INTEGER NOT NULL DEFAULT 0,
|
rx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
tx_bytes INTEGER NOT NULL DEFAULT 0,
|
tx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -533,18 +534,80 @@ CREATE TABLE IF NOT EXISTS user_interface_bindings (
|
|||||||
server_id INTEGER NOT NULL,
|
server_id INTEGER NOT NULL,
|
||||||
interface_name TEXT NOT NULL,
|
interface_name TEXT NOT NULL,
|
||||||
interface_type TEXT NOT NULL DEFAULT 'other',
|
interface_type TEXT NOT NULL DEFAULT 'other',
|
||||||
|
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
peer_name TEXT NOT NULL DEFAULT '',
|
||||||
comment TEXT NOT NULL DEFAULT '',
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
|
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
UNIQUE (server_id, interface_name)
|
UNIQUE (server_id, interface_name, peer_public_key)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user
|
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user
|
||||||
ON user_interface_bindings(user_id);
|
ON user_interface_bindings(user_id);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// Lightweight schema evolution for existing databases without migrations
|
// Lightweight schema evolution for existing databases without migrations
|
||||||
|
{
|
||||||
|
const sampleCols = sqlite.prepare(`PRAGMA table_info('traffic_samples')`).all() as Array<{ name?: string }>
|
||||||
|
if (!sampleCols.some((c) => c.name === "peer_public_key")) {
|
||||||
|
sqlite.exec(`ALTER TABLE traffic_samples ADD COLUMN peer_public_key TEXT NOT NULL DEFAULT ''`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const bindCols = sqlite.prepare(`PRAGMA table_info('user_interface_bindings')`).all() as Array<{ name?: string }>
|
||||||
|
if (!bindCols.some((c) => c.name === "peer_public_key")) {
|
||||||
|
sqlite.exec(`ALTER TABLE user_interface_bindings ADD COLUMN peer_public_key TEXT NOT NULL DEFAULT ''`)
|
||||||
|
}
|
||||||
|
if (!bindCols.some((c) => c.name === "peer_name")) {
|
||||||
|
sqlite.exec(`ALTER TABLE user_interface_bindings ADD COLUMN peer_name TEXT NOT NULL DEFAULT ''`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexes = sqlite.prepare(`PRAGMA index_list('user_interface_bindings')`).all() as Array<{
|
||||||
|
name?: string
|
||||||
|
unique?: number
|
||||||
|
}>
|
||||||
|
let hasPeerUnique = false
|
||||||
|
for (const idx of indexes) {
|
||||||
|
if (!idx.name || !idx.unique) continue
|
||||||
|
const info = sqlite.prepare(`PRAGMA index_info(${JSON.stringify(idx.name)})`).all() as Array<{ name?: string }>
|
||||||
|
const names = info.map((c) => c.name)
|
||||||
|
if (names.includes("server_id") && names.includes("interface_name") && names.includes("peer_public_key")) {
|
||||||
|
hasPeerUnique = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasPeerUnique) {
|
||||||
|
sqlite.exec(`PRAGMA foreign_keys = OFF`)
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE user_interface_bindings_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
server_id INTEGER NOT NULL,
|
||||||
|
interface_name TEXT NOT NULL,
|
||||||
|
interface_type TEXT NOT NULL DEFAULT 'other',
|
||||||
|
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
peer_name TEXT NOT NULL DEFAULT '',
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE (server_id, interface_name, peer_public_key)
|
||||||
|
);
|
||||||
|
INSERT INTO user_interface_bindings_new
|
||||||
|
(id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name, comment, created_at, updated_at)
|
||||||
|
SELECT id, user_id, server_id, interface_name, interface_type,
|
||||||
|
COALESCE(peer_public_key, ''), COALESCE(peer_name, ''), comment, created_at, updated_at
|
||||||
|
FROM user_interface_bindings;
|
||||||
|
DROP TABLE user_interface_bindings;
|
||||||
|
ALTER TABLE user_interface_bindings_new RENAME TO user_interface_bindings;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
|
||||||
|
`)
|
||||||
|
sqlite.exec(`PRAGMA foreign_keys = ON`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const recursiveCols = sqlite.prepare(`PRAGMA table_info('recursive_routes')`).all() as Array<{ name?: string }>
|
const recursiveCols = sqlite.prepare(`PRAGMA table_info('recursive_routes')`).all() as Array<{ name?: string }>
|
||||||
const hasCountryColumn = recursiveCols.some((c) => c.name === "country")
|
const hasCountryColumn = recursiveCols.some((c) => c.name === "country")
|
||||||
if (!hasCountryColumn) {
|
if (!hasCountryColumn) {
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ export const trafficSamples = sqliteTable("traffic_samples", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => servers.id, { onDelete: "cascade" }),
|
.references(() => servers.id, { onDelete: "cascade" }),
|
||||||
interfaceName: text("interface_name").notNull(),
|
interfaceName: text("interface_name").notNull(),
|
||||||
|
peerPublicKey: text("peer_public_key").notNull().default(""),
|
||||||
sampledAt: text("sampled_at").notNull(),
|
sampledAt: text("sampled_at").notNull(),
|
||||||
rxBytes: integer("rx_bytes").notNull().default(0),
|
rxBytes: integer("rx_bytes").notNull().default(0),
|
||||||
txBytes: integer("tx_bytes").notNull().default(0),
|
txBytes: integer("tx_bytes").notNull().default(0),
|
||||||
@@ -570,11 +571,13 @@ export const userInterfaceBindings = sqliteTable("user_interface_bindings", {
|
|||||||
interfaceType: text("interface_type", { enum: ["ether", "gre", "wg", "other"] })
|
interfaceType: text("interface_type", { enum: ["ether", "gre", "wg", "other"] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("other"),
|
.default("other"),
|
||||||
|
peerPublicKey: text("peer_public_key").notNull().default(""),
|
||||||
|
peerName: text("peer_name").notNull().default(""),
|
||||||
comment: text("comment").notNull().default(""),
|
comment: text("comment").notNull().default(""),
|
||||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||||
}, (t) => [
|
}, (t) => [
|
||||||
uniqueIndex("idx_user_iface_bind_server_name").on(t.serverId, t.interfaceName),
|
uniqueIndex("idx_user_iface_bind_server_name_peer").on(t.serverId, t.interfaceName, t.peerPublicKey),
|
||||||
])
|
])
|
||||||
|
|
||||||
export const internetPathSnapshots = sqliteTable("internet_path_snapshots", {
|
export const internetPathSnapshots = sqliteTable("internet_path_snapshots", {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import Database from "better-sqlite3"
|
import Database from "better-sqlite3"
|
||||||
|
import { normalizeBindingPeer, PeerBindError } from "./peer-bind.js"
|
||||||
|
|
||||||
const sqlite = new Database(":memory:")
|
const sqlite = new Database(":memory:")
|
||||||
sqlite.pragma("foreign_keys = ON")
|
sqlite.pragma("foreign_keys = ON")
|
||||||
@@ -29,12 +30,14 @@ CREATE TABLE user_interface_bindings (
|
|||||||
server_id INTEGER NOT NULL,
|
server_id INTEGER NOT NULL,
|
||||||
interface_name TEXT NOT NULL,
|
interface_name TEXT NOT NULL,
|
||||||
interface_type TEXT NOT NULL DEFAULT 'other',
|
interface_type TEXT NOT NULL DEFAULT 'other',
|
||||||
|
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
peer_name TEXT NOT NULL DEFAULT '',
|
||||||
comment TEXT NOT NULL DEFAULT '',
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
|
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
UNIQUE (server_id, interface_name)
|
UNIQUE (server_id, interface_name, peer_public_key)
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
@@ -55,8 +58,33 @@ assert.throws(
|
|||||||
"один интерфейс на сервере — один пользователь",
|
"один интерфейс на сервере — один пользователь",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sqlite.prepare(`
|
||||||
|
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
|
||||||
|
VALUES ('wg1', 'u1', 1, 'wg-server', 'wg', 'peer-key-aaa', 'phone')
|
||||||
|
`).run()
|
||||||
|
sqlite.prepare(`
|
||||||
|
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
|
||||||
|
VALUES ('wg2', 'u2', 1, 'wg-server', 'wg', 'peer-key-bbb', 'laptop')
|
||||||
|
`).run()
|
||||||
|
assert.throws(
|
||||||
|
() => sqlite.prepare(`
|
||||||
|
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key)
|
||||||
|
VALUES ('wg3', 'u2', 1, 'wg-server', 'wg', 'peer-key-aaa')
|
||||||
|
`).run(),
|
||||||
|
/UNIQUE/i,
|
||||||
|
"один пир — один пользователь",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeBindingPeer("wg", ""),
|
||||||
|
(err: unknown) => err instanceof PeerBindError && err.status === 400,
|
||||||
|
"WG без ключа — 400",
|
||||||
|
)
|
||||||
|
assert.equal(normalizeBindingPeer("ether", "ignored"), "")
|
||||||
|
assert.equal(normalizeBindingPeer("wg", " abc "), "abc")
|
||||||
|
|
||||||
sqlite.prepare("DELETE FROM app_users WHERE id = 'u1'").run()
|
sqlite.prepare("DELETE FROM app_users WHERE id = 'u1'").run()
|
||||||
const leftover = sqlite.prepare("SELECT COUNT(*) AS n FROM user_interface_bindings").get() as { n: number }
|
const leftover = sqlite.prepare("SELECT COUNT(*) AS n FROM user_interface_bindings").get() as { n: number }
|
||||||
assert.equal(leftover.n, 0, "каскад: привязки удаляются вместе с пользователем")
|
assert.equal(leftover.n, 1, "каскад: привязки u1 удаляются, пир u2 остаётся")
|
||||||
|
|
||||||
console.log("users bindings unique+cascade tests ok")
|
console.log("users bindings unique+cascade tests ok")
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { InterfaceType } from "./iface-type.js"
|
||||||
|
|
||||||
|
export class PeerBindError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status: number,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = "PeerBindError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncPeerKey(key: string): string {
|
||||||
|
const k = key.trim()
|
||||||
|
if (k.length <= 20) return k
|
||||||
|
return `${k.slice(0, 8)}…${k.slice(-8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function peerDisplayName(opts: {
|
||||||
|
publicKey: string
|
||||||
|
name?: string | null
|
||||||
|
comment?: string | null
|
||||||
|
}): string {
|
||||||
|
const name = (opts.name ?? "").trim()
|
||||||
|
if (name) return name
|
||||||
|
const comment = (opts.comment ?? "").trim()
|
||||||
|
if (comment) return comment
|
||||||
|
return truncPeerKey(opts.publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ether/GRE — пустой ключ. WG — обязательный public-key. */
|
||||||
|
export function normalizeBindingPeer(
|
||||||
|
type: InterfaceType,
|
||||||
|
peerPublicKey: string | undefined,
|
||||||
|
): string {
|
||||||
|
const key = (peerPublicKey ?? "").trim()
|
||||||
|
if (type === "wg") {
|
||||||
|
if (!key) {
|
||||||
|
throw new PeerBindError("Для WireGuard укажите пир (public-key)", 400)
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -46,9 +46,10 @@ export function getBindingRowById(id: string): BindingRow | undefined {
|
|||||||
return db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).limit(1).all()[0]
|
return db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).limit(1).all()[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBindingByServerIface(
|
export function getBindingByServerIfacePeer(
|
||||||
serverId: number,
|
serverId: number,
|
||||||
interfaceName: string,
|
interfaceName: string,
|
||||||
|
peerPublicKey = "",
|
||||||
): BindingRow | undefined {
|
): BindingRow | undefined {
|
||||||
return db
|
return db
|
||||||
.select()
|
.select()
|
||||||
@@ -56,6 +57,7 @@ export function getBindingByServerIface(
|
|||||||
.where(and(
|
.where(and(
|
||||||
eq(userInterfaceBindings.serverId, serverId),
|
eq(userInterfaceBindings.serverId, serverId),
|
||||||
eq(userInterfaceBindings.interfaceName, interfaceName),
|
eq(userInterfaceBindings.interfaceName, interfaceName),
|
||||||
|
eq(userInterfaceBindings.peerPublicKey, peerPublicKey),
|
||||||
))
|
))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.all()[0]
|
.all()[0]
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
createUserRow,
|
createUserRow,
|
||||||
deleteBindingRowById,
|
deleteBindingRowById,
|
||||||
deleteUserRowById,
|
deleteUserRowById,
|
||||||
getBindingByServerIface,
|
getBindingByServerIfacePeer,
|
||||||
getBindingRowById,
|
getBindingRowById,
|
||||||
getUserRowById,
|
getUserRowById,
|
||||||
getUserRowByLogin,
|
getUserRowByLogin,
|
||||||
@@ -35,6 +35,12 @@ import {
|
|||||||
mapRosInterfaceType,
|
mapRosInterfaceType,
|
||||||
parseRawInterfaces,
|
parseRawInterfaces,
|
||||||
} from "../iface-type.js"
|
} from "../iface-type.js"
|
||||||
|
import {
|
||||||
|
normalizeBindingPeer,
|
||||||
|
PeerBindError,
|
||||||
|
peerDisplayName,
|
||||||
|
} from "../peer-bind.js"
|
||||||
|
import { listWireGuardPeersForCatalog } from "../../../services/wireguard-live.js"
|
||||||
|
|
||||||
export class UsersServiceError extends Error {
|
export class UsersServiceError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -80,6 +86,8 @@ function toBindingDto(row: BindingRow): UserBinding {
|
|||||||
serverCountry: meta.country,
|
serverCountry: meta.country,
|
||||||
interfaceName: row.interfaceName,
|
interfaceName: row.interfaceName,
|
||||||
interfaceType: row.interfaceType,
|
interfaceType: row.interfaceType,
|
||||||
|
peerPublicKey: row.peerPublicKey ?? "",
|
||||||
|
peerName: row.peerName ?? "",
|
||||||
comment: row.comment,
|
comment: row.comment,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
@@ -179,11 +187,29 @@ export function addBinding(userId: string, input: UserBindingCreate): UserBindin
|
|||||||
if (!server) throw new UsersServiceError("Сервер не найден", 404)
|
if (!server) throw new UsersServiceError("Сервер не найден", 404)
|
||||||
const ifaceName = input.interfaceName.trim()
|
const ifaceName = input.interfaceName.trim()
|
||||||
if (!ifaceName) throw new UsersServiceError("Имя интерфейса обязательно", 400)
|
if (!ifaceName) throw new UsersServiceError("Имя интерфейса обязательно", 400)
|
||||||
const taken = getBindingByServerIface(input.serverId, ifaceName)
|
|
||||||
if (taken) {
|
|
||||||
throw new UsersServiceError("Интерфейс уже привязан к другому пользователю", 409)
|
|
||||||
}
|
|
||||||
const type: InterfaceType = input.interfaceType ?? inferIfaceType(input.serverId, ifaceName)
|
const type: InterfaceType = input.interfaceType ?? inferIfaceType(input.serverId, ifaceName)
|
||||||
|
let peerPublicKey = ""
|
||||||
|
try {
|
||||||
|
peerPublicKey = normalizeBindingPeer(type, input.peerPublicKey)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof PeerBindError) throw new UsersServiceError(err.message, err.status)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
const peerName = type === "wg"
|
||||||
|
? peerDisplayName({
|
||||||
|
publicKey: peerPublicKey,
|
||||||
|
name: input.peerName,
|
||||||
|
})
|
||||||
|
: ""
|
||||||
|
const taken = getBindingByServerIfacePeer(input.serverId, ifaceName, peerPublicKey)
|
||||||
|
if (taken) {
|
||||||
|
throw new UsersServiceError(
|
||||||
|
type === "wg"
|
||||||
|
? "Этот пир уже привязан к другому пользователю"
|
||||||
|
: "Интерфейс уже привязан к другому пользователю",
|
||||||
|
409,
|
||||||
|
)
|
||||||
|
}
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
try {
|
try {
|
||||||
const row = createBindingRow({
|
const row = createBindingRow({
|
||||||
@@ -192,6 +218,8 @@ export function addBinding(userId: string, input: UserBindingCreate): UserBindin
|
|||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
interfaceName: ifaceName,
|
interfaceName: ifaceName,
|
||||||
interfaceType: type,
|
interfaceType: type,
|
||||||
|
peerPublicKey,
|
||||||
|
peerName,
|
||||||
comment: (input.comment ?? "").trim(),
|
comment: (input.comment ?? "").trim(),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -199,7 +227,12 @@ export function addBinding(userId: string, input: UserBindingCreate): UserBindin
|
|||||||
return toBindingDto(row)
|
return toBindingDto(row)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isUniqueConstraintError(err)) {
|
if (isUniqueConstraintError(err)) {
|
||||||
throw new UsersServiceError("Интерфейс уже привязан к другому пользователю", 409)
|
throw new UsersServiceError(
|
||||||
|
type === "wg"
|
||||||
|
? "Этот пир уже привязан к другому пользователю"
|
||||||
|
: "Интерфейс уже привязан к другому пользователю",
|
||||||
|
409,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
@@ -220,7 +253,7 @@ function inferIfaceType(serverId: number, ifaceName: string): InterfaceType {
|
|||||||
return found?.type ?? "other"
|
return found?.type ?? "other"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listInterfaceCatalog(serverId: number): CatalogInterface[] {
|
export async function listInterfaceCatalog(serverId: number): Promise<CatalogInterface[]> {
|
||||||
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||||
if (!server) throw new UsersServiceError("Сервер не найден", 404)
|
if (!server) throw new UsersServiceError("Сервер не найден", 404)
|
||||||
|
|
||||||
@@ -238,13 +271,14 @@ export function listInterfaceCatalog(serverId: number): CatalogInterface[] {
|
|||||||
const rows = db
|
const rows = db
|
||||||
.select({
|
.select({
|
||||||
interfaceName: trafficSamples.interfaceName,
|
interfaceName: trafficSamples.interfaceName,
|
||||||
|
peerPublicKey: trafficSamples.peerPublicKey,
|
||||||
running: trafficSamples.running,
|
running: trafficSamples.running,
|
||||||
disabled: trafficSamples.disabled,
|
disabled: trafficSamples.disabled,
|
||||||
})
|
})
|
||||||
.from(trafficSamples)
|
.from(trafficSamples)
|
||||||
.where(eq(trafficSamples.serverId, serverId))
|
.where(eq(trafficSamples.serverId, serverId))
|
||||||
.all()
|
.all()
|
||||||
.filter((r) => r.interfaceName && !/^(lo|loopback)/i.test(r.interfaceName))
|
.filter((r) => r.interfaceName && !/^(lo|loopback)/i.test(r.interfaceName) && !(r.peerPublicKey ?? ""))
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
ifaces = []
|
ifaces = []
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
@@ -262,18 +296,47 @@ export function listInterfaceCatalog(serverId: number): CatalogInterface[] {
|
|||||||
|
|
||||||
const bindings = listBindingRows().filter((b) => b.serverId === serverId)
|
const bindings = listBindingRows().filter((b) => b.serverId === serverId)
|
||||||
const usersById = new Map(listUserRows().map((u) => [u.id, u]))
|
const usersById = new Map(listUserRows().map((u) => [u.id, u]))
|
||||||
|
const hasWg = ifaces.some((i) => i.type === "wg")
|
||||||
|
const wgLive = hasWg
|
||||||
|
? await listWireGuardPeersForCatalog(serverId)
|
||||||
|
: { peers: [] as Awaited<ReturnType<typeof listWireGuardPeersForCatalog>>["peers"] }
|
||||||
|
const peersByIface = new Map<string, typeof wgLive.peers>()
|
||||||
|
for (const peer of wgLive.peers) {
|
||||||
|
const list = peersByIface.get(peer.interfaceName) ?? []
|
||||||
|
list.push(peer)
|
||||||
|
peersByIface.set(peer.interfaceName, list)
|
||||||
|
}
|
||||||
|
|
||||||
return ifaces.map((iface) => {
|
return ifaces.map((iface) => {
|
||||||
const bind = bindings.find((b) => b.interfaceName === iface.name)
|
const ifaceBind = bindings.find((b) => b.interfaceName === iface.name && !(b.peerPublicKey ?? ""))
|
||||||
const owner = bind ? usersById.get(bind.userId) : undefined
|
const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined
|
||||||
return {
|
const base: CatalogInterface = {
|
||||||
name: iface.name,
|
name: iface.name,
|
||||||
type: iface.type,
|
type: iface.type,
|
||||||
running: iface.running,
|
running: iface.running,
|
||||||
disabled: iface.disabled,
|
disabled: iface.disabled,
|
||||||
boundUserId: bind?.userId ?? null,
|
boundUserId: ifaceBind?.userId ?? null,
|
||||||
boundUserLogin: owner?.login ?? null,
|
boundUserLogin: owner?.login ?? null,
|
||||||
}
|
}
|
||||||
|
if (iface.type !== "wg") return base
|
||||||
|
const livePeers = peersByIface.get(iface.name) ?? []
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
peersError: wgLive.error,
|
||||||
|
peers: livePeers.map((p) => {
|
||||||
|
const bind = bindings.find((b) => b.interfaceName === iface.name && b.peerPublicKey === p.publicKey)
|
||||||
|
const peerOwner = bind ? usersById.get(bind.userId) : undefined
|
||||||
|
return {
|
||||||
|
publicKey: p.publicKey,
|
||||||
|
name: peerDisplayName({ publicKey: p.publicKey, name: p.name, comment: p.comment }),
|
||||||
|
comment: p.comment,
|
||||||
|
allowedIps: p.allowedIps,
|
||||||
|
latestHandshake: p.latestHandshake,
|
||||||
|
boundUserId: bind?.userId ?? null,
|
||||||
|
boundUserLogin: peerOwner?.login ?? null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
}).sort((a, b) => a.name.localeCompare(b.name))
|
}).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const usersRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
}, async (req, reply) => {
|
}, async (req, reply) => {
|
||||||
const q = req.query as { serverId: number }
|
const q = req.query as { serverId: number }
|
||||||
try {
|
try {
|
||||||
return reply.send({ interfaces: listInterfaceCatalog(q.serverId) })
|
return reply.send({ interfaces: await listInterfaceCatalog(q.serverId) })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return sendServiceError(reply, err)
|
return sendServiceError(reply, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,38 @@ interface RosIfaceTraffic {
|
|||||||
"tx-bits-per-second"?: string
|
"tx-bits-per-second"?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RosWgPeerTraffic {
|
||||||
|
interface?: string
|
||||||
|
name?: string
|
||||||
|
comment?: string
|
||||||
|
"public-key"?: string
|
||||||
|
rx?: string
|
||||||
|
tx?: string
|
||||||
|
disabled?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function waveKey(interfaceName: string, peerPublicKey = ""): string {
|
||||||
|
return `${interfaceName}\0${peerPublicKey}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleRate(
|
||||||
|
prevWave: Map<string, { rxBytes: number; txBytes: number; sampledAt: string }>,
|
||||||
|
key: string,
|
||||||
|
rxBytes: number,
|
||||||
|
txBytes: number,
|
||||||
|
nowMs: number,
|
||||||
|
): { rxBps: number; txBps: number } {
|
||||||
|
const prev = prevWave.get(key)
|
||||||
|
const prevMs = prev ? Date.parse(prev.sampledAt) : NaN
|
||||||
|
const rxBps = prev && Number.isFinite(prevMs)
|
||||||
|
? (rateBpsFromDelta(prev.rxBytes, rxBytes, prevMs, nowMs) ?? 0)
|
||||||
|
: 0
|
||||||
|
const txBps = prev && Number.isFinite(prevMs)
|
||||||
|
? (rateBpsFromDelta(prev.txBytes, txBytes, prevMs, nowMs) ?? 0)
|
||||||
|
: 0
|
||||||
|
return { rxBps, txBps }
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrafficCollectorState {
|
export interface TrafficCollectorState {
|
||||||
running: boolean
|
running: boolean
|
||||||
lastRunAt: string | null
|
lastRunAt: string | null
|
||||||
@@ -63,6 +95,7 @@ function readPreviousWave(serverId: number): Map<string, { rxBytes: number; txBy
|
|||||||
const rows = db
|
const rows = db
|
||||||
.select({
|
.select({
|
||||||
interfaceName: trafficSamples.interfaceName,
|
interfaceName: trafficSamples.interfaceName,
|
||||||
|
peerPublicKey: trafficSamples.peerPublicKey,
|
||||||
rxBytes: trafficSamples.rxBytes,
|
rxBytes: trafficSamples.rxBytes,
|
||||||
txBytes: trafficSamples.txBytes,
|
txBytes: trafficSamples.txBytes,
|
||||||
sampledAt: trafficSamples.sampledAt,
|
sampledAt: trafficSamples.sampledAt,
|
||||||
@@ -73,7 +106,7 @@ function readPreviousWave(serverId: number): Map<string, { rxBytes: number; txBy
|
|||||||
eq(trafficSamples.sampledAt, last.sampledAt),
|
eq(trafficSamples.sampledAt, last.sampledAt),
|
||||||
))
|
))
|
||||||
.all()
|
.all()
|
||||||
return new Map(rows.map((r) => [r.interfaceName, r]))
|
return new Map(rows.map((r) => [`${r.interfaceName}\0${r.peerPublicKey ?? ""}`, r]))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||||
@@ -116,14 +149,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
|||||||
const txBytes = toNum(i["tx-byte"])
|
const txBytes = toNum(i["tx-byte"])
|
||||||
const running = (i.running ?? "false") === "true"
|
const running = (i.running ?? "false") === "true"
|
||||||
const disabled = (i.disabled ?? "false") === "true"
|
const disabled = (i.disabled ?? "false") === "true"
|
||||||
const prev = prevWave.get(interfaceName)
|
const { rxBps, txBps } = sampleRate(prevWave, waveKey(interfaceName), rxBytes, txBytes, nowMs)
|
||||||
const prevMs = prev ? Date.parse(prev.sampledAt) : NaN
|
|
||||||
const rxBps = prev && Number.isFinite(prevMs)
|
|
||||||
? (rateBpsFromDelta(prev.rxBytes, rxBytes, prevMs, nowMs) ?? 0)
|
|
||||||
: 0
|
|
||||||
const txBps = prev && Number.isFinite(prevMs)
|
|
||||||
? (rateBpsFromDelta(prev.txBytes, txBytes, prevMs, nowMs) ?? 0)
|
|
||||||
: 0
|
|
||||||
if (shouldIncludeIface(interfaceName, running, disabled)) {
|
if (shouldIncludeIface(interfaceName, running, disabled)) {
|
||||||
sumRxMbps += bpsToMbps(rxBps)
|
sumRxMbps += bpsToMbps(rxBps)
|
||||||
sumTxMbps += bpsToMbps(txBps)
|
sumTxMbps += bpsToMbps(txBps)
|
||||||
@@ -131,6 +157,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
|||||||
return {
|
return {
|
||||||
serverId: srv.id,
|
serverId: srv.id,
|
||||||
interfaceName,
|
interfaceName,
|
||||||
|
peerPublicKey: "",
|
||||||
sampledAt: now,
|
sampledAt: now,
|
||||||
rxBytes,
|
rxBytes,
|
||||||
txBytes,
|
txBytes,
|
||||||
@@ -140,6 +167,39 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
|||||||
disabled,
|
disabled,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
try {
|
||||||
|
const peers = await client.get<RosWgPeerTraffic[]>("/interface/wireguard/peers")
|
||||||
|
for (const p of peers) {
|
||||||
|
const interfaceName = (p.interface ?? "").trim()
|
||||||
|
const peerPublicKey = (p["public-key"] ?? "").trim()
|
||||||
|
if (!interfaceName || !peerPublicKey) continue
|
||||||
|
const rxBytes = toNum(p.rx)
|
||||||
|
const txBytes = toNum(p.tx)
|
||||||
|
const disabled = (p.disabled ?? "false") === "true" || p.disabled === "yes"
|
||||||
|
const running = !disabled
|
||||||
|
const { rxBps, txBps } = sampleRate(
|
||||||
|
prevWave,
|
||||||
|
waveKey(interfaceName, peerPublicKey),
|
||||||
|
rxBytes,
|
||||||
|
txBytes,
|
||||||
|
nowMs,
|
||||||
|
)
|
||||||
|
rows.push({
|
||||||
|
serverId: srv.id,
|
||||||
|
interfaceName,
|
||||||
|
peerPublicKey,
|
||||||
|
sampledAt: now,
|
||||||
|
rxBytes,
|
||||||
|
txBytes,
|
||||||
|
rxBps,
|
||||||
|
txBps,
|
||||||
|
running,
|
||||||
|
disabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* WG peers optional — iface samples already recorded */
|
||||||
|
}
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
db.insert(trafficSamples).values(rows).run()
|
db.insert(trafficSamples).values(rows).run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,4 +97,29 @@ const listed = buildTrafficFromSamples([...samples, ...wgSamples], start, end, [
|
|||||||
assert.equal(userAgg.rxNow, listed.rxNow, "сумма привязанных ifaces = фильтр по списку имён")
|
assert.equal(userAgg.rxNow, listed.rxNow, "сумма привязанных ifaces = фильтр по списку имён")
|
||||||
assert.ok(userAgg.rxNow > built.rxNow, "агрегация пользователя больше одного iface")
|
assert.ok(userAgg.rxNow > built.rxNow, "агрегация пользователя больше одного iface")
|
||||||
|
|
||||||
|
const peerA: TrafficSampleLike[] = [
|
||||||
|
{ interfaceName: "wg-server", peerPublicKey: "peer-a", sampledAt: t0, rxBytes: 1_000_000, txBytes: 100_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
{ interfaceName: "wg-server", peerPublicKey: "peer-a", sampledAt: t1, rxBytes: 1_000_000 + 3_750_000, txBytes: 100_000 + 375_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
]
|
||||||
|
const peerB: TrafficSampleLike[] = [
|
||||||
|
{ interfaceName: "wg-server", peerPublicKey: "peer-b", sampledAt: t0, rxBytes: 500_000, txBytes: 50_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
{ interfaceName: "wg-server", peerPublicKey: "peer-b", sampledAt: t1, rxBytes: 500_000 + 1_875_000, txBytes: 50_000 + 187_500, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
]
|
||||||
|
const ifaceWg: TrafficSampleLike[] = [
|
||||||
|
{ interfaceName: "wg-server", sampledAt: t0, rxBytes: 10_000_000, txBytes: 2_000_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
{ interfaceName: "wg-server", sampledAt: t1, rxBytes: 10_000_000 + 7_500_000, txBytes: 2_000_000 + 750_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
]
|
||||||
|
const mixed = [...peerA, ...peerB, ...ifaceWg]
|
||||||
|
const rateA = buildTrafficFromSamples(mixed, start, end, "wg-server", "peer-a")
|
||||||
|
const rateB = buildTrafficFromSamples(mixed, start, end, "wg-server", "peer-b")
|
||||||
|
const rateIface = buildTrafficFromSamples(mixed, start, end, "wg-server")
|
||||||
|
assert.ok(rateA.rxNow > 0 && rateB.rxNow > 0, "скорость по каждому пиру")
|
||||||
|
assert.notEqual(rateA.rxNow, rateB.rxNow, "два пира одного iface — разный rate")
|
||||||
|
assert.ok(rateIface.rxNow > rateA.rxNow, "iface-level не суммирует пиров")
|
||||||
|
assert.equal(
|
||||||
|
buildTrafficFromSamples(mixed, start, end).rxNow,
|
||||||
|
rateIface.rxNow,
|
||||||
|
"режим сервера игнорирует семплы пиров",
|
||||||
|
)
|
||||||
|
|
||||||
console.log("traffic-rate tests ok")
|
console.log("traffic-rate tests ok")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const SERIES_POINTS = 60
|
|||||||
|
|
||||||
export interface TrafficSampleLike {
|
export interface TrafficSampleLike {
|
||||||
interfaceName: string
|
interfaceName: string
|
||||||
|
peerPublicKey?: string
|
||||||
sampledAt: string
|
sampledAt: string
|
||||||
rxBytes: number
|
rxBytes: number
|
||||||
txBytes: number
|
txBytes: number
|
||||||
@@ -94,11 +95,16 @@ function parseIsoMs(iso: string): number {
|
|||||||
return Number.isFinite(t) ? t : 0
|
return Number.isFinite(t) ? t : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sampleSeriesKey(interfaceName: string, peerPublicKey = ""): string {
|
||||||
|
return `${interfaceName}\0${peerPublicKey}`
|
||||||
|
}
|
||||||
|
|
||||||
export function buildTrafficFromSamples(
|
export function buildTrafficFromSamples(
|
||||||
rows: TrafficSampleLike[],
|
rows: TrafficSampleLike[],
|
||||||
rangeStartMs: number,
|
rangeStartMs: number,
|
||||||
rangeEndMs: number,
|
rangeEndMs: number,
|
||||||
onlyInterface?: string | readonly string[],
|
onlyInterface?: string | readonly string[],
|
||||||
|
peerPublicKey?: string,
|
||||||
): BuiltTrafficSeries {
|
): BuiltTrafficSeries {
|
||||||
const empty: BuiltTrafficSeries = {
|
const empty: BuiltTrafficSeries = {
|
||||||
rxNow: 0,
|
rxNow: 0,
|
||||||
@@ -113,11 +119,12 @@ export function buildTrafficFromSamples(
|
|||||||
}
|
}
|
||||||
if (rows.length === 0) return empty
|
if (rows.length === 0) return empty
|
||||||
|
|
||||||
const byIface = new Map<string, TrafficSampleLike[]>()
|
const bySeries = new Map<string, TrafficSampleLike[]>()
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const arr = byIface.get(r.interfaceName) ?? []
|
const peer = r.peerPublicKey ?? ""
|
||||||
|
const arr = bySeries.get(sampleSeriesKey(r.interfaceName, peer)) ?? []
|
||||||
arr.push(r)
|
arr.push(r)
|
||||||
byIface.set(r.interfaceName, arr)
|
bySeries.set(sampleSeriesKey(r.interfaceName, peer), arr)
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowList = Array.isArray(onlyInterface)
|
const allowList = Array.isArray(onlyInterface)
|
||||||
@@ -132,12 +139,20 @@ export function buildTrafficFromSamples(
|
|||||||
let txBytesDelta = 0
|
let txBytesDelta = 0
|
||||||
let sessions = 0
|
let sessions = 0
|
||||||
|
|
||||||
for (const [name, arr] of byIface) {
|
for (const [key, arr] of bySeries) {
|
||||||
|
const sep = key.indexOf("\0")
|
||||||
|
const name = sep >= 0 ? key.slice(0, sep) : key
|
||||||
|
const peer = sep >= 0 ? key.slice(sep + 1) : ""
|
||||||
if (allowList) {
|
if (allowList) {
|
||||||
if (!allowList.includes(name)) continue
|
if (!allowList.includes(name)) continue
|
||||||
} else if (isLoopbackName(name)) {
|
} else if (isLoopbackName(name)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (peerPublicKey === undefined) {
|
||||||
|
if (peer !== "") continue
|
||||||
|
} else if (peer !== peerPublicKey) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
|
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
|
||||||
const last = sorted[sorted.length - 1]
|
const last = sorted[sorted.length - 1]
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface BoundIfaceTrafficDto {
|
|||||||
userName: string
|
userName: string
|
||||||
interfaceName: string
|
interfaceName: string
|
||||||
interfaceType: string
|
interfaceType: string
|
||||||
|
peerPublicKey: string
|
||||||
|
peerName: string
|
||||||
comment: string
|
comment: string
|
||||||
serverId: string
|
serverId: string
|
||||||
serverName: string
|
serverName: string
|
||||||
@@ -77,22 +79,6 @@ export function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number):
|
|||||||
return users.map((user) => {
|
return users.map((user) => {
|
||||||
const parts: BuiltTrafficSeries[] = []
|
const parts: BuiltTrafficSeries[] = []
|
||||||
const interfaces: BoundIfaceTrafficDto[] = []
|
const interfaces: BoundIfaceTrafficDto[] = []
|
||||||
const byServer = new Map<number, string[]>()
|
|
||||||
for (const b of user.bindings) {
|
|
||||||
const arr = byServer.get(b.serverId) ?? []
|
|
||||||
arr.push(b.interfaceName)
|
|
||||||
byServer.set(b.serverId, arr)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [serverId, names] of byServer) {
|
|
||||||
let rows = sampleCache.get(serverId)
|
|
||||||
if (!rows) {
|
|
||||||
rows = readServerSamplesInRange(serverId, sinceIso)
|
|
||||||
sampleCache.set(serverId, rows)
|
|
||||||
}
|
|
||||||
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, names)
|
|
||||||
parts.push(built)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const b of user.bindings) {
|
for (const b of user.bindings) {
|
||||||
let rows = sampleCache.get(b.serverId)
|
let rows = sampleCache.get(b.serverId)
|
||||||
@@ -100,19 +86,25 @@ export function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number):
|
|||||||
rows = readServerSamplesInRange(b.serverId, sinceIso)
|
rows = readServerSamplesInRange(b.serverId, sinceIso)
|
||||||
sampleCache.set(b.serverId, rows)
|
sampleCache.set(b.serverId, rows)
|
||||||
}
|
}
|
||||||
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, b.interfaceName)
|
const peerKey = b.peerPublicKey ?? ""
|
||||||
const last = [...rows.filter((r) => r.interfaceName === b.interfaceName)]
|
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, b.interfaceName, peerKey)
|
||||||
|
parts.push(built)
|
||||||
|
const last = [...rows.filter((r) =>
|
||||||
|
r.interfaceName === b.interfaceName && (r.peerPublicKey ?? "") === peerKey,
|
||||||
|
)]
|
||||||
.sort((a, c) => a.sampledAt.localeCompare(c.sampledAt))
|
.sort((a, c) => a.sampledAt.localeCompare(c.sampledAt))
|
||||||
.at(-1)
|
.at(-1)
|
||||||
const running = Boolean(last?.running) && !last?.disabled
|
const running = Boolean(last?.running) && !last?.disabled
|
||||||
interfaces.push({
|
interfaces.push({
|
||||||
id: `${b.userId}:${b.serverId}:${b.interfaceName}`,
|
id: `${b.userId}:${b.serverId}:${b.interfaceName}:${peerKey || "_iface"}`,
|
||||||
bindingId: b.id,
|
bindingId: b.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
userLogin: user.login,
|
userLogin: user.login,
|
||||||
userName: user.name,
|
userName: user.name,
|
||||||
interfaceName: b.interfaceName,
|
interfaceName: b.interfaceName,
|
||||||
interfaceType: b.interfaceType,
|
interfaceType: b.interfaceType,
|
||||||
|
peerPublicKey: peerKey,
|
||||||
|
peerName: b.peerName ?? "",
|
||||||
comment: b.comment,
|
comment: b.comment,
|
||||||
serverId: String(b.serverId),
|
serverId: String(b.serverId),
|
||||||
serverName: b.serverName,
|
serverName: b.serverName,
|
||||||
|
|||||||
@@ -211,4 +211,51 @@ export function getEnabledServerById(serverId: string | number): ServerRow | nul
|
|||||||
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] ?? null
|
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CatalogWgPeer = {
|
||||||
|
interfaceName: string
|
||||||
|
publicKey: string
|
||||||
|
name: string
|
||||||
|
comment: string
|
||||||
|
allowedIps: string[]
|
||||||
|
latestHandshake?: string
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const WG_CATALOG_TIMEOUT_MS = 5_000
|
||||||
|
|
||||||
|
export async function listWireGuardPeersForCatalog(serverId: number): Promise<{
|
||||||
|
peers: CatalogWgPeer[]
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
const row = getEnabledServerById(serverId)
|
||||||
|
if (!row) return { peers: [], error: "Сервер не найден" }
|
||||||
|
try {
|
||||||
|
const client = MikrotikClient.fromServer(row)
|
||||||
|
const peersRaw = await Promise.race([
|
||||||
|
client.get<RosWireGuardPeer[]>("/interface/wireguard/peers"),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error("Таймаут RouterOS")), WG_CATALOG_TIMEOUT_MS)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
const peers: CatalogWgPeer[] = peersRaw.flatMap((p, idx) => {
|
||||||
|
const mapped = mapPeer(p, idx)
|
||||||
|
const interfaceName = (p.interface ?? "").trim()
|
||||||
|
const publicKey = mapped.publicKey.trim()
|
||||||
|
if (!interfaceName || !publicKey) return []
|
||||||
|
return [{
|
||||||
|
interfaceName,
|
||||||
|
publicKey,
|
||||||
|
name: mapped.name ?? "",
|
||||||
|
comment: mapped.comment ?? "",
|
||||||
|
allowedIps: mapped.allowedIps,
|
||||||
|
latestHandshake: mapped.latestHandshake,
|
||||||
|
disabled: mapped.disabled === true,
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
return { peers }
|
||||||
|
} catch (e) {
|
||||||
|
return { peers: [], error: e instanceof Error ? e.message : String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export { type RosWireGuard, type RosWireGuardPeer }
|
export { type RosWireGuard, type RosWireGuardPeer }
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
type AppUser,
|
type AppUser,
|
||||||
type InterfaceType,
|
type InterfaceType,
|
||||||
} from "@/lib/users"
|
} 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"> = {
|
const TYPE_VARIANT: Record<InterfaceType, "outline" | "info-light" | "success-light" | "secondary"> = {
|
||||||
ether: "outline",
|
ether: "outline",
|
||||||
@@ -87,23 +87,29 @@ function UsersExpandedDetail({
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
|
||||||
{items.map((b) => {
|
{items.map((b) => {
|
||||||
const meta = TYPE_ICON[b.interfaceType]
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={b.id}
|
key={b.id}
|
||||||
className="flex items-start gap-2 rounded-md border px-3 py-2.5"
|
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 />
|
<Icon />
|
||||||
</IconTile>
|
</IconTile>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-mono font-medium leading-tight truncate">
|
<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>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-1 mt-0.5">
|
<div className="flex flex-wrap items-center gap-1 mt-0.5">
|
||||||
<Badge variant={TYPE_VARIANT[b.interfaceType]} size="sm">
|
<Badge variant={TYPE_VARIANT[b.interfaceType]} size="sm">
|
||||||
{IFACE_TYPE_LABEL[b.interfaceType]}
|
{IFACE_TYPE_LABEL[b.interfaceType]}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{b.interfaceType === "wg" && !(b.peerPublicKey ?? "") ? (
|
||||||
|
<Badge variant="warning-light" size="sm">весь интерфейс</Badge>
|
||||||
|
) : null}
|
||||||
{b.comment ? (
|
{b.comment ? (
|
||||||
<span className="text-[10px] text-muted-foreground leading-tight truncate">
|
<span className="text-[10px] text-muted-foreground leading-tight truncate">
|
||||||
{b.comment}
|
{b.comment}
|
||||||
|
|||||||
+229
-59
@@ -19,12 +19,23 @@ import {
|
|||||||
StepperTrigger,
|
StepperTrigger,
|
||||||
} from "@/components/reui/stepper"
|
} from "@/components/reui/stepper"
|
||||||
import { Badge } from "@/components/reui/badge"
|
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 { Flag } from "@/components/flag"
|
||||||
import { StatusDot } from "@/components/status-dot"
|
import { StatusDot } from "@/components/status-dot"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { listInterfaceCatalog } from "@/shared/api/users"
|
import { listInterfaceCatalog } from "@/shared/api/users"
|
||||||
import { TYPE_VARIANT } from "@/components/data-grids/users-expanded-detail"
|
import { TYPE_VARIANT } from "@/components/data-grids/users-expanded-detail"
|
||||||
import {
|
import {
|
||||||
|
bindingDiffKey,
|
||||||
|
bindingTitle,
|
||||||
catalogForServer,
|
catalogForServer,
|
||||||
defaultSections,
|
defaultSections,
|
||||||
defaultServers,
|
defaultServers,
|
||||||
@@ -43,10 +54,28 @@ import {
|
|||||||
type UserServerOption,
|
type UserServerOption,
|
||||||
} from "@/lib/users"
|
} from "@/lib/users"
|
||||||
import {
|
import {
|
||||||
LayoutDashboardIcon, EyeIcon, PlusIcon, ServerIcon, ShieldIcon,
|
CableIcon, ChevronDownIcon, EyeIcon, KeyRoundIcon, LayoutDashboardIcon,
|
||||||
TrashIcon, WrenchIcon,
|
NetworkIcon, PlusIcon, ServerIcon, ShieldIcon, TrashIcon, WrenchIcon,
|
||||||
} from "lucide-react"
|
} 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> = {
|
const SECTION_GROUP_ICONS: Record<string, ReactNode> = {
|
||||||
"Обзор": <LayoutDashboardIcon className="size-3" />,
|
"Обзор": <LayoutDashboardIcon className="size-3" />,
|
||||||
"Данные": <EyeIcon className="size-3" />,
|
"Данные": <EyeIcon className="size-3" />,
|
||||||
@@ -124,7 +153,8 @@ function UserSheet({
|
|||||||
const [errors, setErrors] = useState<Partial<Record<keyof AppUserForm, string>>>({})
|
const [errors, setErrors] = useState<Partial<Record<keyof AppUserForm, string>>>({})
|
||||||
const [catalogServerId, setCatalogServerId] = useState(servers[0]?.id ?? "")
|
const [catalogServerId, setCatalogServerId] = useState(servers[0]?.id ?? "")
|
||||||
const [catalog, setCatalog] = useState<CatalogIface[]>([])
|
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("")
|
const [newComment, setNewComment] = useState("")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -133,7 +163,8 @@ function UserSheet({
|
|||||||
setSheetStep(1)
|
setSheetStep(1)
|
||||||
setErrors({})
|
setErrors({})
|
||||||
setCatalogServerId(servers[0]?.id ?? "")
|
setCatalogServerId(servers[0]?.id ?? "")
|
||||||
setSelectedNames([])
|
setSelectedPicks([])
|
||||||
|
setExpandedWg(null)
|
||||||
setNewComment("")
|
setNewComment("")
|
||||||
}, [open, user, servers])
|
}, [open, user, servers])
|
||||||
|
|
||||||
@@ -212,29 +243,32 @@ function UserSheet({
|
|||||||
const catalogSrv = servers.find((s) => s.id === catalogServerId)
|
const catalogSrv = servers.find((s) => s.id === catalogServerId)
|
||||||
|
|
||||||
const addSelectedBindings = () => {
|
const addSelectedBindings = () => {
|
||||||
if (!catalogSrv || selectedNames.length === 0) return
|
if (!catalogSrv || selectedPicks.length === 0) return
|
||||||
const existing = new Set(form.bindings.map((b) => `${b.serverId}::${b.interfaceName}`))
|
const existing = new Set(form.bindings.map(bindingDiffKey))
|
||||||
const next: InterfaceBinding[] = [...form.bindings]
|
const next: InterfaceBinding[] = [...form.bindings]
|
||||||
for (const name of selectedNames) {
|
for (const pick of selectedPicks) {
|
||||||
const key = `${catalogSrv.id}::${name}`
|
const key = bindingDiffKey({
|
||||||
|
serverId: catalogSrv.id,
|
||||||
|
interfaceName: pick.interfaceName,
|
||||||
|
peerPublicKey: pick.peerPublicKey,
|
||||||
|
})
|
||||||
if (existing.has(key)) continue
|
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({
|
next.push({
|
||||||
id: `pending-${catalogSrv.id}-${name}`,
|
id: `pending-${catalogSrv.id}-${pick.interfaceName}-${pick.peerPublicKey || "iface"}`,
|
||||||
userId: user?.id ?? "",
|
userId: user?.id ?? "",
|
||||||
serverId: catalogSrv.id,
|
serverId: catalogSrv.id,
|
||||||
serverName: catalogSrv.name,
|
serverName: catalogSrv.name,
|
||||||
serverSite: catalogSrv.site,
|
serverSite: catalogSrv.site,
|
||||||
serverCountry: catalogSrv.country,
|
serverCountry: catalogSrv.country,
|
||||||
interfaceName: name,
|
interfaceName: pick.interfaceName,
|
||||||
interfaceType: iface.type,
|
interfaceType: pick.type,
|
||||||
|
peerPublicKey: pick.peerPublicKey || undefined,
|
||||||
|
peerName: pick.peerName,
|
||||||
comment: newComment.trim(),
|
comment: newComment.trim(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
setForm((f) => ({ ...f, bindings: next }))
|
setForm((f) => ({ ...f, bindings: next }))
|
||||||
setSelectedNames([])
|
setSelectedPicks([])
|
||||||
setNewComment("")
|
setNewComment("")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,15 +276,26 @@ function UserSheet({
|
|||||||
setForm((f) => ({ ...f, bindings: f.bindings.filter((b) => b.id !== id) }))
|
setForm((f) => ({ ...f, bindings: f.bindings.filter((b) => b.id !== id) }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleName = (name: string) => {
|
const togglePick = (pick: CatalogPick) => {
|
||||||
setSelectedNames((prev) => prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name])
|
setSelectedPicks((prev) => {
|
||||||
|
const key = pickKey(pick)
|
||||||
|
return prev.some((p) => pickKey(p) === key)
|
||||||
|
? prev.filter((p) => pickKey(p) !== key)
|
||||||
|
: [...prev, pick]
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const alreadyBoundHere = useMemo(
|
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],
|
[form.bindings, catalogServerId],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const selectedPickKeys = useMemo(() => new Set(selectedPicks.map(pickKey)), [selectedPicks])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
<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">
|
<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">
|
<StepperContent value={4} className="flex flex-col">
|
||||||
<p className="text-[11px] text-muted-foreground py-2.5 border-b">
|
<p className="text-[11px] text-muted-foreground py-2.5 border-b">
|
||||||
Привязка интерфейсов сервера. Один интерфейс — один пользователь.
|
Ethernet и GRE — целиком. WireGuard — только пир (public-key).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="py-3 flex flex-col gap-3 border-b">
|
<div className="py-3 flex flex-col gap-3 border-b">
|
||||||
@@ -419,56 +464,178 @@ function UserSheet({
|
|||||||
<select
|
<select
|
||||||
className="h-8 rounded-md border bg-background px-2 text-xs font-mono"
|
className="h-8 rounded-md border bg-background px-2 text-xs font-mono"
|
||||||
value={catalogServerId}
|
value={catalogServerId}
|
||||||
onChange={(e) => { setCatalogServerId(e.target.value); setSelectedNames([]) }}
|
onChange={(e) => { setCatalogServerId(e.target.value); setSelectedPicks([]); setExpandedWg(null) }}
|
||||||
>
|
>
|
||||||
{servers.map((s) => (
|
{servers.map((s) => (
|
||||||
<option key={s.id} value={s.id}>{s.name} · {s.site}</option>
|
<option key={s.id} value={s.id}>{s.name} · {s.site}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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">
|
<Frame dense spacing="sm">
|
||||||
{catalog.length === 0 && (
|
<FramePanel className="max-h-64 overflow-y-auto p-1.5">
|
||||||
<p className="text-[11px] text-muted-foreground py-1">Нет интерфейсов в каталоге</p>
|
{catalog.length === 0 && (
|
||||||
)}
|
<p className="px-2 py-3 text-center text-[11px] text-muted-foreground">Нет интерфейсов в каталоге</p>
|
||||||
{catalog.map((iface) => {
|
)}
|
||||||
const taken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id)
|
<div className="flex flex-col gap-0.5">
|
||||||
const mine = alreadyBoundHere.has(iface.name)
|
{catalog.map((iface) => {
|
||||||
const disabled = taken || mine
|
const tile = IFACE_TILE[iface.type]
|
||||||
return (
|
const Icon = tile.icon
|
||||||
<label
|
if (iface.type === "wg") {
|
||||||
key={iface.name}
|
const open = expandedWg === iface.name
|
||||||
className={cn(
|
const legacyTaken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id)
|
||||||
"flex items-center gap-2 py-0.5 text-xs",
|
const legacyMine = alreadyBoundHere.has(`${iface.name}\0`)
|
||||||
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
|
return (
|
||||||
)}
|
<div key={iface.name} className="flex flex-col gap-0.5">
|
||||||
>
|
<Item
|
||||||
<input
|
size="xs"
|
||||||
type="checkbox"
|
variant={open ? "muted" : "default"}
|
||||||
disabled={disabled}
|
render={<button type="button" onClick={() => setExpandedWg(open ? null : iface.name)} />}
|
||||||
checked={selectedNames.includes(iface.name)}
|
className="h-11 min-h-11 flex-nowrap rounded-md py-0"
|
||||||
onChange={() => toggleName(iface.name)}
|
>
|
||||||
className="rounded border-input accent-primary"
|
<ItemMedia>
|
||||||
/>
|
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", tile.className)}>
|
||||||
<span className="font-mono">{iface.name}</span>
|
<Icon />
|
||||||
<Badge variant={TYPE_VARIANT[iface.type as InterfaceType]} size="sm">
|
</IconTile>
|
||||||
{IFACE_TYPE_LABEL[iface.type as InterfaceType]}
|
</ItemMedia>
|
||||||
</Badge>
|
<ItemContent className="min-w-0">
|
||||||
{taken && (
|
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
|
||||||
<span className="text-[10px] text-muted-foreground ml-auto">{iface.boundUserLogin}</span>
|
<span className="min-w-0 truncate">{iface.name}</span>
|
||||||
)}
|
<Badge variant={TYPE_VARIANT.wg} size="sm">WireGuard</Badge>
|
||||||
{mine && !taken && (
|
</ItemTitle>
|
||||||
<span className="text-[10px] text-muted-foreground ml-auto">уже привязан</span>
|
</ItemContent>
|
||||||
)}
|
<ItemActions>
|
||||||
</label>
|
{legacyMine ? (
|
||||||
)
|
<Badge variant="warning-light" size="xs">весь интерфейс</Badge>
|
||||||
})}
|
) : null}
|
||||||
</div>
|
<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
|
<Input
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
placeholder="Комментарий (необязательно)"
|
placeholder="Комментарий (необязательно)"
|
||||||
value={newComment}
|
value={newComment}
|
||||||
onChange={(e) => setNewComment(e.target.value)}
|
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" />Привязать
|
<PlusIcon className="size-3.5" />Привязать
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -480,8 +647,11 @@ function UserSheet({
|
|||||||
{form.bindings.map((b) => (
|
{form.bindings.map((b) => (
|
||||||
<div key={b.id} className="flex items-center gap-2 py-1">
|
<div key={b.id} className="flex items-center gap-2 py-1">
|
||||||
<Flag code={b.serverCountry} size={12} />
|
<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>
|
<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>
|
<span className="text-[10px] text-muted-foreground truncate">{b.serverName}</span>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
+78
-13
@@ -15,9 +15,32 @@ export interface InterfaceBinding {
|
|||||||
serverCountry: string
|
serverCountry: string
|
||||||
interfaceName: string
|
interfaceName: string
|
||||||
interfaceType: InterfaceType
|
interfaceType: InterfaceType
|
||||||
|
peerPublicKey?: string
|
||||||
|
peerName?: string
|
||||||
comment: string
|
comment: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CatalogPeer {
|
||||||
|
publicKey: string
|
||||||
|
name: string
|
||||||
|
comment: string
|
||||||
|
allowedIps: string[]
|
||||||
|
latestHandshake?: string
|
||||||
|
boundUserId: string | null
|
||||||
|
boundUserLogin: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogIface {
|
||||||
|
name: string
|
||||||
|
type: InterfaceType
|
||||||
|
running: boolean
|
||||||
|
disabled: boolean
|
||||||
|
boundUserId: string | null
|
||||||
|
boundUserLogin: string | null
|
||||||
|
peers?: CatalogPeer[]
|
||||||
|
peersError?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppUserForm {
|
export interface AppUserForm {
|
||||||
name: string
|
name: string
|
||||||
login: string
|
login: string
|
||||||
@@ -43,15 +66,6 @@ export interface AppUser {
|
|||||||
bindings: InterfaceBinding[]
|
bindings: InterfaceBinding[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CatalogIface {
|
|
||||||
name: string
|
|
||||||
type: InterfaceType
|
|
||||||
running: boolean
|
|
||||||
disabled: boolean
|
|
||||||
boundUserId: string | null
|
|
||||||
boundUserLogin: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserServerOption {
|
export interface UserServerOption {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -138,17 +152,39 @@ export const MOCK_IFACE_CATALOG: Record<string, CatalogIface[]> = {
|
|||||||
{ name: "gre-datacenter", type: "gre", running: true, disabled: false, boundUserId: "u1", boundUserLogin: "[email protected]" },
|
{ name: "gre-datacenter", type: "gre", running: true, disabled: false, boundUserId: "u1", boundUserLogin: "[email protected]" },
|
||||||
{ name: "gre-spb-branch", type: "gre", running: true, disabled: false, boundUserId: "u2", boundUserLogin: "[email protected]" },
|
{ name: "gre-spb-branch", type: "gre", running: true, disabled: false, boundUserId: "u2", boundUserLogin: "[email protected]" },
|
||||||
{ name: "gre-retail-01", type: "gre", running: false, disabled: false, boundUserId: "u4", boundUserLogin: "[email protected]" },
|
{ name: "gre-retail-01", type: "gre", running: false, disabled: false, boundUserId: "u4", boundUserLogin: "[email protected]" },
|
||||||
{ name: "wg-msk-spb", type: "wg", running: true, disabled: false, boundUserId: "u1", boundUserLogin: "[email protected]" },
|
{
|
||||||
|
name: "wg-msk-spb", type: "wg", running: true, disabled: false, boundUserId: null, boundUserLogin: null,
|
||||||
|
peers: [
|
||||||
|
{ publicKey: "mockPeerKeyAAAA0123456789", name: "phone-ak", comment: "", allowedIps: ["10.8.0.2/32"], boundUserId: "u1", boundUserLogin: "[email protected]" },
|
||||||
|
{ publicKey: "mockPeerKeyBBBB0123456789", name: "laptop-ak", comment: "", allowedIps: ["10.8.0.3/32"], boundUserId: null, boundUserLogin: null, latestHandshake: "12s" },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
srv7: [
|
srv7: [
|
||||||
{ name: "ether1", type: "ether", running: true, disabled: false, boundUserId: null, boundUserLogin: null },
|
{ name: "ether1", type: "ether", running: true, disabled: false, boundUserId: null, boundUserLogin: null },
|
||||||
{ name: "gre-office-msk", type: "gre", running: true, disabled: false, boundUserId: "u1", boundUserLogin: "[email protected]" },
|
{ name: "gre-office-msk", type: "gre", running: true, disabled: false, boundUserId: "u1", boundUserLogin: "[email protected]" },
|
||||||
{ name: "gre-warehouse", type: "gre", running: false, disabled: false, boundUserId: "u1", boundUserLogin: "[email protected]" },
|
{ name: "gre-warehouse", type: "gre", running: false, disabled: false, boundUserId: "u1", boundUserLogin: "[email protected]" },
|
||||||
{ name: "gre-spb-branch", type: "gre", running: true, disabled: false, boundUserId: "u2", boundUserLogin: "[email protected]" },
|
{ name: "gre-spb-branch", type: "gre", running: true, disabled: false, boundUserId: "u2", boundUserLogin: "[email protected]" },
|
||||||
{ name: "wg-lab", type: "wg", running: true, disabled: false, boundUserId: null, boundUserLogin: null },
|
{
|
||||||
|
name: "wg-lab", type: "wg", running: true, disabled: false, boundUserId: null, boundUserLogin: null,
|
||||||
|
peers: [
|
||||||
|
{ publicKey: "mockPeerKeyLABB0123456789", name: "lab-peer", comment: "", allowedIps: ["10.9.0.2/32"], boundUserId: null, boundUserLogin: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function bindingDiffKey(b: Pick<InterfaceBinding, "serverId" | "interfaceName" | "peerPublicKey">): string {
|
||||||
|
return `${b.serverId}::${b.interfaceName}::${b.peerPublicKey ?? ""}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindingTitle(b: Pick<InterfaceBinding, "interfaceName" | "interfaceType" | "peerName" | "peerPublicKey">): string {
|
||||||
|
if (b.interfaceType === "wg" && (b.peerName || b.peerPublicKey)) {
|
||||||
|
return `${b.peerName || "peer"} · ${b.interfaceName}`
|
||||||
|
}
|
||||||
|
return b.interfaceName
|
||||||
|
}
|
||||||
|
|
||||||
function bind(
|
function bind(
|
||||||
id: string,
|
id: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -156,6 +192,7 @@ function bind(
|
|||||||
interfaceName: string,
|
interfaceName: string,
|
||||||
interfaceType: InterfaceType,
|
interfaceType: InterfaceType,
|
||||||
comment: string,
|
comment: string,
|
||||||
|
peer?: { publicKey: string; name: string },
|
||||||
): InterfaceBinding {
|
): InterfaceBinding {
|
||||||
const srv = servers.find((s) => s.id === serverId)
|
const srv = servers.find((s) => s.id === serverId)
|
||||||
return {
|
return {
|
||||||
@@ -167,6 +204,8 @@ function bind(
|
|||||||
serverCountry: srv?.country ?? "UN",
|
serverCountry: srv?.country ?? "UN",
|
||||||
interfaceName,
|
interfaceName,
|
||||||
interfaceType,
|
interfaceType,
|
||||||
|
peerPublicKey: peer?.publicKey,
|
||||||
|
peerName: peer?.name,
|
||||||
comment,
|
comment,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,7 +219,7 @@ export const INIT_USERS: AppUser[] = [
|
|||||||
bind("b1", "u1", "srv1", "ether1", "ether", "Uplink MSK"),
|
bind("b1", "u1", "srv1", "ether1", "ether", "Uplink MSK"),
|
||||||
bind("b2", "u1", "srv1", "gre-office-msk", "gre", "Офис MSK"),
|
bind("b2", "u1", "srv1", "gre-office-msk", "gre", "Офис MSK"),
|
||||||
bind("b3", "u1", "srv1", "gre-datacenter", "gre", "ЦОД"),
|
bind("b3", "u1", "srv1", "gre-datacenter", "gre", "ЦОД"),
|
||||||
bind("b4", "u1", "srv1", "wg-msk-spb", "wg", "Overlay SPB"),
|
bind("b4", "u1", "srv1", "wg-msk-spb", "wg", "Overlay SPB", { publicKey: "mockPeerKeyAAAA0123456789", name: "phone-ak" }),
|
||||||
bind("b5", "u1", "srv7", "gre-office-msk", "gre", "Офис LAB"),
|
bind("b5", "u1", "srv7", "gre-office-msk", "gre", "Офис LAB"),
|
||||||
bind("b6", "u1", "srv7", "gre-warehouse", "gre", "Склад"),
|
bind("b6", "u1", "srv7", "gre-warehouse", "gre", "Склад"),
|
||||||
],
|
],
|
||||||
@@ -213,8 +252,34 @@ export const INIT_USERS: AppUser[] = [
|
|||||||
export function catalogForServer(serverId: string, users: AppUser[]): CatalogIface[] {
|
export function catalogForServer(serverId: string, users: AppUser[]): CatalogIface[] {
|
||||||
const base = MOCK_IFACE_CATALOG[serverId] ?? []
|
const base = MOCK_IFACE_CATALOG[serverId] ?? []
|
||||||
return base.map((iface) => {
|
return base.map((iface) => {
|
||||||
|
if (iface.type === "wg") {
|
||||||
|
const peers = (iface.peers ?? []).map((peer) => {
|
||||||
|
const owner = users.find((u) =>
|
||||||
|
u.bindings.some((b) =>
|
||||||
|
b.serverId === serverId
|
||||||
|
&& b.interfaceName === iface.name
|
||||||
|
&& (b.peerPublicKey ?? "") === peer.publicKey,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!owner) return { ...peer, boundUserId: null, boundUserLogin: null }
|
||||||
|
return { ...peer, boundUserId: owner.id, boundUserLogin: owner.email || owner.login }
|
||||||
|
})
|
||||||
|
const legacy = users.find((u) =>
|
||||||
|
u.bindings.some((b) =>
|
||||||
|
b.serverId === serverId
|
||||||
|
&& b.interfaceName === iface.name
|
||||||
|
&& !(b.peerPublicKey ?? ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...iface,
|
||||||
|
boundUserId: legacy?.id ?? null,
|
||||||
|
boundUserLogin: legacy ? (legacy.email || legacy.login) : null,
|
||||||
|
peers,
|
||||||
|
}
|
||||||
|
}
|
||||||
const owner = users.find((u) =>
|
const owner = users.find((u) =>
|
||||||
u.bindings.some((b) => b.serverId === serverId && b.interfaceName === iface.name),
|
u.bindings.some((b) => b.serverId === serverId && b.interfaceName === iface.name && !(b.peerPublicKey ?? "")),
|
||||||
)
|
)
|
||||||
if (!owner) return { ...iface, boundUserId: null, boundUserLogin: null }
|
if (!owner) return { ...iface, boundUserId: null, boundUserLogin: null }
|
||||||
return { ...iface, boundUserId: owner.id, boundUserLogin: owner.email || owner.login }
|
return { ...iface, boundUserId: owner.id, boundUserLogin: owner.email || owner.login }
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export const userBindingSchema = z.object({
|
|||||||
serverCountry: z.string(),
|
serverCountry: z.string(),
|
||||||
interfaceName: z.string().min(1),
|
interfaceName: z.string().min(1),
|
||||||
interfaceType: interfaceTypeSchema,
|
interfaceType: interfaceTypeSchema,
|
||||||
|
peerPublicKey: z.string().default(""),
|
||||||
|
peerName: z.string().default(""),
|
||||||
comment: z.string(),
|
comment: z.string(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
updatedAt: z.string(),
|
updatedAt: z.string(),
|
||||||
@@ -71,6 +73,8 @@ export const userBindingCreateSchema = z.object({
|
|||||||
serverId: z.coerce.number().int().positive(),
|
serverId: z.coerce.number().int().positive(),
|
||||||
interfaceName: z.string().min(1),
|
interfaceName: z.string().min(1),
|
||||||
interfaceType: interfaceTypeSchema.optional(),
|
interfaceType: interfaceTypeSchema.optional(),
|
||||||
|
peerPublicKey: z.string().optional(),
|
||||||
|
peerName: z.string().optional(),
|
||||||
comment: z.string().optional(),
|
comment: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -78,6 +82,16 @@ export const interfaceCatalogQuerySchema = z.object({
|
|||||||
serverId: z.coerce.number().int().positive(),
|
serverId: z.coerce.number().int().positive(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const catalogPeerSchema = z.object({
|
||||||
|
publicKey: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
comment: z.string(),
|
||||||
|
allowedIps: z.array(z.string()),
|
||||||
|
latestHandshake: z.string().optional(),
|
||||||
|
boundUserId: z.string().nullable(),
|
||||||
|
boundUserLogin: z.string().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
export const catalogInterfaceSchema = z.object({
|
export const catalogInterfaceSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
type: interfaceTypeSchema,
|
type: interfaceTypeSchema,
|
||||||
@@ -85,6 +99,8 @@ export const catalogInterfaceSchema = z.object({
|
|||||||
disabled: z.boolean(),
|
disabled: z.boolean(),
|
||||||
boundUserId: z.string().nullable(),
|
boundUserId: z.string().nullable(),
|
||||||
boundUserLogin: z.string().nullable(),
|
boundUserLogin: z.string().nullable(),
|
||||||
|
peers: z.array(catalogPeerSchema).optional(),
|
||||||
|
peersError: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const appUserListSchema = z.array(appUserReadSchema)
|
export const appUserListSchema = z.array(appUserReadSchema)
|
||||||
@@ -99,4 +115,5 @@ export type AppUserRead = z.infer<typeof appUserReadSchema>
|
|||||||
export type AppUserCreate = z.infer<typeof appUserCreateSchema>
|
export type AppUserCreate = z.infer<typeof appUserCreateSchema>
|
||||||
export type AppUserUpdate = z.infer<typeof appUserUpdateSchema>
|
export type AppUserUpdate = z.infer<typeof appUserUpdateSchema>
|
||||||
export type UserBindingCreate = z.infer<typeof userBindingCreateSchema>
|
export type UserBindingCreate = z.infer<typeof userBindingCreateSchema>
|
||||||
|
export type CatalogPeer = z.infer<typeof catalogPeerSchema>
|
||||||
export type CatalogInterface = z.infer<typeof catalogInterfaceSchema>
|
export type CatalogInterface = z.infer<typeof catalogInterfaceSchema>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export function toFrontendBinding(b: UserBinding): InterfaceBinding {
|
|||||||
serverCountry: b.serverCountry,
|
serverCountry: b.serverCountry,
|
||||||
interfaceName: b.interfaceName,
|
interfaceName: b.interfaceName,
|
||||||
interfaceType: b.interfaceType,
|
interfaceType: b.interfaceType,
|
||||||
|
peerPublicKey: b.peerPublicKey || undefined,
|
||||||
|
peerName: b.peerName || undefined,
|
||||||
comment: b.comment,
|
comment: b.comment,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user