feat(users): implement user management features and database schema
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-image (push) Successful in 1m57s
Docker images / frontend-image (push) Successful in 4m8s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 44s
Docker images / publish-release (push) Successful in 11s
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-image (push) Successful in 1m57s
Docker images / frontend-image (push) Successful in 4m8s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 44s
Docker images / publish-release (push) Successful in 11s
Added user management functionality, including the creation of app_users and user_interface_bindings tables in the database. Implemented API routes for user data retrieval and permissions handling. Enhanced the traffic monitoring system to include user traffic statistics and interface bindings. Updated relevant components and services to support the new user features, improving overall application functionality and user experience.
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
bucketAvg,
|
||||
buildTrafficFromSamples,
|
||||
isLoopbackName,
|
||||
mergeBuiltTraffic,
|
||||
parseMonitorTraffic,
|
||||
rateBpsFromDelta,
|
||||
shouldIncludeIface,
|
||||
@@ -73,4 +74,27 @@ const onceOnly = parseMonitorTraffic(
|
||||
)
|
||||
assert.equal(onceOnly.rxMbps, 1)
|
||||
|
||||
const boundOnly = buildTrafficFromSamples(samples, start, end, ["ether1", "lo"])
|
||||
assert.ok(boundOnly.rxPeak > 0, "bound list includes ether1")
|
||||
assert.ok(boundOnly.rxPeak > built.rxPeak, "lo included when listed")
|
||||
|
||||
const merged = mergeBuiltTraffic([
|
||||
buildTrafficFromSamples(samples, start, end, "ether1"),
|
||||
buildTrafficFromSamples(samples, start, end, "ether1"),
|
||||
])
|
||||
assert.equal(merged.rxNow, built.rxNow * 2)
|
||||
|
||||
const wgSamples: TrafficSampleLike[] = [
|
||||
{ interfaceName: "wg-msk", sampledAt: t0, rxBytes: 2_000_000, txBytes: 1_000_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "wg-msk", sampledAt: t1, rxBytes: 2_000_000 + 1_875_000, txBytes: 1_000_000 + 937_500, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "wg-msk", sampledAt: t2, rxBytes: 50, txBytes: 25, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
]
|
||||
const userAgg = mergeBuiltTraffic([
|
||||
buildTrafficFromSamples(samples, start, end, "ether1"),
|
||||
buildTrafficFromSamples(wgSamples, start, end, "wg-msk"),
|
||||
])
|
||||
const listed = buildTrafficFromSamples([...samples, ...wgSamples], start, end, ["ether1", "wg-msk"])
|
||||
assert.equal(userAgg.rxNow, listed.rxNow, "сумма привязанных ifaces = фильтр по списку имён")
|
||||
assert.ok(userAgg.rxNow > built.rxNow, "агрегация пользователя больше одного iface")
|
||||
|
||||
console.log("traffic-rate tests ok")
|
||||
|
||||
@@ -98,7 +98,7 @@ export function buildTrafficFromSamples(
|
||||
rows: TrafficSampleLike[],
|
||||
rangeStartMs: number,
|
||||
rangeEndMs: number,
|
||||
onlyInterface?: string,
|
||||
onlyInterface?: string | readonly string[],
|
||||
): BuiltTrafficSeries {
|
||||
const empty: BuiltTrafficSeries = {
|
||||
rxNow: 0,
|
||||
@@ -120,6 +120,10 @@ export function buildTrafficFromSamples(
|
||||
byIface.set(r.interfaceName, arr)
|
||||
}
|
||||
|
||||
const allowList = Array.isArray(onlyInterface)
|
||||
? onlyInterface
|
||||
: (typeof onlyInterface === "string" ? [onlyInterface] : null)
|
||||
|
||||
const rxPoints: Array<{ t: number; v: number }> = []
|
||||
const txPoints: Array<{ t: number; v: number }> = []
|
||||
const byTs = new Map<number, { rx: number; tx: number }>()
|
||||
@@ -129,8 +133,8 @@ export function buildTrafficFromSamples(
|
||||
let sessions = 0
|
||||
|
||||
for (const [name, arr] of byIface) {
|
||||
if (onlyInterface) {
|
||||
if (name !== onlyInterface) continue
|
||||
if (allowList) {
|
||||
if (!allowList.includes(name)) continue
|
||||
} else if (isLoopbackName(name)) {
|
||||
continue
|
||||
}
|
||||
@@ -138,7 +142,7 @@ export function buildTrafficFromSamples(
|
||||
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
|
||||
const last = sorted[sorted.length - 1]
|
||||
if (!last) continue
|
||||
if (!onlyInterface && (!last.running || last.disabled)) continue
|
||||
if (!allowList && (!last.running || last.disabled)) continue
|
||||
|
||||
if (last.running && !last.disabled) sessions += 1
|
||||
|
||||
@@ -154,7 +158,7 @@ export function buildTrafficFromSamples(
|
||||
const prev = sorted[i - 1]
|
||||
const cur = sorted[i]
|
||||
if (!prev || !cur) continue
|
||||
if (!onlyInterface && (!cur.running || cur.disabled)) continue
|
||||
if (!allowList && (!cur.running || cur.disabled)) continue
|
||||
const t0 = parseIsoMs(prev.sampledAt)
|
||||
const t1 = parseIsoMs(cur.sampledAt)
|
||||
const rxBps = rateBpsFromDelta(prev.rxBytes, cur.rxBytes, t0, t1)
|
||||
@@ -194,6 +198,48 @@ export function buildTrafficFromSamples(
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeBuiltTraffic(parts: BuiltTrafficSeries[]): BuiltTrafficSeries {
|
||||
const empty: BuiltTrafficSeries = {
|
||||
rxNow: 0,
|
||||
txNow: 0,
|
||||
rxPeak: 0,
|
||||
txPeak: 0,
|
||||
rxTotalGiB: 0,
|
||||
txTotalGiB: 0,
|
||||
sessions: 0,
|
||||
rxSeries: Array.from({ length: SERIES_POINTS }, () => 0),
|
||||
txSeries: Array.from({ length: SERIES_POINTS }, () => 0),
|
||||
}
|
||||
if (parts.length === 0) return empty
|
||||
const acc = {
|
||||
rxNow: 0,
|
||||
txNow: 0,
|
||||
rxPeak: 0,
|
||||
txPeak: 0,
|
||||
rxTotalGiB: 0,
|
||||
txTotalGiB: 0,
|
||||
sessions: 0,
|
||||
rxSeries: Array.from({ length: SERIES_POINTS }, () => 0),
|
||||
txSeries: Array.from({ length: SERIES_POINTS }, () => 0),
|
||||
}
|
||||
for (const p of parts) {
|
||||
acc.rxNow += p.rxNow
|
||||
acc.txNow += p.txNow
|
||||
acc.rxPeak += p.rxPeak
|
||||
acc.txPeak += p.txPeak
|
||||
acc.rxTotalGiB += p.rxTotalGiB
|
||||
acc.txTotalGiB += p.txTotalGiB
|
||||
acc.sessions += p.sessions
|
||||
for (let i = 0; i < SERIES_POINTS; i++) {
|
||||
acc.rxSeries[i] += p.rxSeries[i] ?? 0
|
||||
acc.txSeries[i] += p.txSeries[i] ?? 0
|
||||
}
|
||||
}
|
||||
acc.rxTotalGiB = Number(acc.rxTotalGiB.toFixed(1))
|
||||
acc.txTotalGiB = Number(acc.txTotalGiB.toFixed(1))
|
||||
return acc
|
||||
}
|
||||
|
||||
export function parseMonitorTraffic(
|
||||
raw: unknown,
|
||||
opts?: { onlyInterface?: string },
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers } from "../db/schema.js"
|
||||
import { listUsers } from "../modules/users/service/users-service.js"
|
||||
import { readServerSamplesInRange } from "./traffic-collector.js"
|
||||
import {
|
||||
buildTrafficFromSamples,
|
||||
mergeBuiltTraffic,
|
||||
type BuiltTrafficSeries,
|
||||
} from "./traffic-rate.js"
|
||||
|
||||
export interface BoundIfaceTrafficDto {
|
||||
id: string
|
||||
bindingId: string
|
||||
userId: string
|
||||
userLogin: string
|
||||
userName: string
|
||||
interfaceName: string
|
||||
interfaceType: string
|
||||
comment: string
|
||||
serverId: string
|
||||
serverName: string
|
||||
serverSite: string
|
||||
serverCountry: string
|
||||
rxNow: number
|
||||
txNow: number
|
||||
rxPeak: number
|
||||
txPeak: number
|
||||
rxTotal: number
|
||||
txTotal: number
|
||||
rxSeries: number[]
|
||||
txSeries: number[]
|
||||
status: "online" | "offline"
|
||||
}
|
||||
|
||||
export interface UserTrafficDto {
|
||||
id: string
|
||||
login: string
|
||||
displayName: string
|
||||
role: string
|
||||
active: boolean
|
||||
interfaces: BoundIfaceTrafficDto[]
|
||||
rxNow: number
|
||||
txNow: number
|
||||
rxPeak: number
|
||||
txPeak: number
|
||||
rxTotal: number
|
||||
txTotal: number
|
||||
rxSeries: number[]
|
||||
txSeries: number[]
|
||||
}
|
||||
|
||||
function seriesFromBuilt(built: BuiltTrafficSeries) {
|
||||
return {
|
||||
rxNow: built.rxNow,
|
||||
txNow: built.txNow,
|
||||
rxPeak: built.rxPeak,
|
||||
txPeak: built.txPeak,
|
||||
rxTotal: built.rxTotalGiB,
|
||||
txTotal: built.txTotalGiB,
|
||||
rxSeries: built.rxSeries,
|
||||
txSeries: built.txSeries,
|
||||
}
|
||||
}
|
||||
|
||||
function serverStatus(serverId: number): "online" | "offline" {
|
||||
const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||
if (!row?.enabled) return "offline"
|
||||
return "online"
|
||||
}
|
||||
|
||||
export function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number): UserTrafficDto[] {
|
||||
const sinceIso = new Date(rangeStartMs).toISOString()
|
||||
const users = listUsers()
|
||||
const sampleCache = new Map<number, ReturnType<typeof readServerSamplesInRange>>()
|
||||
|
||||
return users.map((user) => {
|
||||
const parts: BuiltTrafficSeries[] = []
|
||||
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) {
|
||||
let rows = sampleCache.get(b.serverId)
|
||||
if (!rows) {
|
||||
rows = readServerSamplesInRange(b.serverId, sinceIso)
|
||||
sampleCache.set(b.serverId, rows)
|
||||
}
|
||||
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, b.interfaceName)
|
||||
const last = [...rows.filter((r) => r.interfaceName === b.interfaceName)]
|
||||
.sort((a, c) => a.sampledAt.localeCompare(c.sampledAt))
|
||||
.at(-1)
|
||||
const running = Boolean(last?.running) && !last?.disabled
|
||||
interfaces.push({
|
||||
id: `${b.userId}:${b.serverId}:${b.interfaceName}`,
|
||||
bindingId: b.id,
|
||||
userId: user.id,
|
||||
userLogin: user.login,
|
||||
userName: user.name,
|
||||
interfaceName: b.interfaceName,
|
||||
interfaceType: b.interfaceType,
|
||||
comment: b.comment,
|
||||
serverId: String(b.serverId),
|
||||
serverName: b.serverName,
|
||||
serverSite: b.serverSite,
|
||||
serverCountry: b.serverCountry,
|
||||
...seriesFromBuilt(built),
|
||||
status: running && serverStatus(b.serverId) === "online" ? "online" : "offline",
|
||||
})
|
||||
}
|
||||
|
||||
const merged = mergeBuiltTraffic(parts)
|
||||
return {
|
||||
id: user.id,
|
||||
login: user.login,
|
||||
displayName: user.name,
|
||||
role: user.role,
|
||||
active: user.active,
|
||||
interfaces,
|
||||
...seriesFromBuilt(merged),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function buildBoundInterfaceTraffic(rangeStartMs: number, rangeEndMs: number): BoundIfaceTrafficDto[] {
|
||||
return buildUserTrafficList(rangeStartMs, rangeEndMs).flatMap((u) => u.interfaces)
|
||||
}
|
||||
Reference in New Issue
Block a user