feat(traffic): разделить плоскости IPFIX и показать путь JH→EN
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Failing after 1m31s
Docker images / frontend-image (push) Successful in 2m36s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 42s
Docker images / publish-release (push) Skipped
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Failing after 1m31s
Docker images / frontend-image (push) Successful in 2m36s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 42s
Docker images / publish-release (push) Skipped
Считать payload отдельно от overlay GRE/ESP и mesh; вкладка Пути и KPI Wire из счётчиков iface. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -28,12 +28,19 @@ function TrafficFlowsDataGrid({
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<FlowTalkerDto>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "client",
|
||||
accessorFn: (r) => r.clientName ?? "",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Клиент</span>,
|
||||
cell: ({ row }) => <span className="text-xs">{row.original.clientName || "—"}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||
},
|
||||
{
|
||||
id: "server",
|
||||
accessorKey: "serverName",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">JH</span>,
|
||||
cell: ({ row }) => <span className="text-sm font-medium">{row.original.serverName}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "src",
|
||||
@@ -96,6 +103,20 @@ function TrafficFlowsDataGrid({
|
||||
cell: ({ row }) => <span className="text-xs tabular-nums">{formatBytes(row.original.bytes)}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "en",
|
||||
accessorFn: (r) => r.enName ?? "",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">EN</span>,
|
||||
cell: ({ row }) => <span className="text-xs">{row.original.enName || "—"}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "plane",
|
||||
accessorFn: (r) => r.plane ?? "",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Плоскость</span>,
|
||||
cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.original.plane || "—"}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "iface",
|
||||
accessorKey: "inIface",
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table"
|
||||
import type { FlowAnalyticsDto, FlowBreakdownRow, FlowEntityCard, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
||||
import { ArrowDownIcon, ArrowUpIcon, GitBranchIcon, GlobeIcon, LayersIcon, UsersIcon } from "lucide-react"
|
||||
import type { FlowAnalyticsDto, FlowBreakdownRow, FlowEntityCard, FlowPathRow, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
||||
import { ArrowDownIcon, ArrowUpIcon, GitBranchIcon, GlobeIcon, LayersIcon, NetworkIcon, RouteIcon, ShieldIcon, UsersIcon } from "lucide-react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { KpiStatGrid, type KpiStatItem } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
@@ -110,7 +110,7 @@ export function FlowEntityCardView({
|
||||
}
|
||||
|
||||
type SessionFilter = {
|
||||
kind: "application" | "category" | "service" | "asn" | "country" | "protocol" | "source" | "destination" | "iface"
|
||||
kind: "application" | "category" | "service" | "asn" | "country" | "protocol" | "source" | "destination" | "iface" | "client" | "en"
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
@@ -126,6 +126,8 @@ function talkerMatchesFilter(row: FlowTalkerDto, filter: SessionFilter): boolean
|
||||
case "source": return row.src === filter.value
|
||||
case "destination": return row.dst === filter.value
|
||||
case "iface": return row.inIface === filter.value
|
||||
case "client": return (row.clientId || "unknown") === filter.value
|
||||
case "en": return (row.enId || "") === filter.value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +212,101 @@ function FlowBreakdownGrid({
|
||||
)
|
||||
}
|
||||
|
||||
function FlowPathsGrid({
|
||||
rows,
|
||||
empty,
|
||||
onPick,
|
||||
}: {
|
||||
rows: FlowPathRow[]
|
||||
empty?: string
|
||||
onPick?: (row: FlowPathRow) => void
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<FlowPathRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "client",
|
||||
accessorKey: "clientName",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Клиент</span>,
|
||||
cell: ({ row }) => <span className="text-sm font-medium">{row.original.clientName}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||
},
|
||||
{
|
||||
id: "ifaces",
|
||||
accessorKey: "ifaces",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Ifaces</span>,
|
||||
cell: ({ row }) => <span className="font-mono text-xs text-muted-foreground">{row.original.ifaces}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "jh",
|
||||
accessorKey: "serverName",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">JH</span>,
|
||||
cell: ({ row }) => <span className="text-xs">{row.original.serverName}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "in",
|
||||
accessorKey: "inIface",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">In</span>,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.inIface}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "en",
|
||||
accessorKey: "enName",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">EN</span>,
|
||||
cell: ({ row }) => <span className="text-xs">{row.original.enName || "—"}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "dst",
|
||||
accessorKey: "dst",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Dest</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="flex min-w-0 flex-col gap-0.5 text-xs">
|
||||
<span className="font-mono">{row.original.dst}</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{[row.original.category, row.original.service].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "rate",
|
||||
accessorFn: (r) => r.bps,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Скорость</span>,
|
||||
cell: ({ row }) => <span className="text-xs tabular-nums">{fmtRate(row.original.bps / 1_000_000)}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "bytes",
|
||||
accessorKey: "bytes",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Байты</span>,
|
||||
cell: ({ row }) => <span className="text-xs tabular-nums">{formatBytes(row.original.bytes)}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rows.length}
|
||||
emptyMessage={empty ?? "Нет путей"}
|
||||
onRowClick={onPick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function FlowAnalyticsDetail({
|
||||
card,
|
||||
analytics,
|
||||
@@ -219,6 +316,10 @@ export function FlowAnalyticsDetail({
|
||||
onIface,
|
||||
dedup,
|
||||
onDedup,
|
||||
excludeMesh,
|
||||
onExcludeMesh,
|
||||
excludeOverlay,
|
||||
onExcludeOverlay,
|
||||
liveHint,
|
||||
emptyHint,
|
||||
}: {
|
||||
@@ -230,6 +331,10 @@ export function FlowAnalyticsDetail({
|
||||
onIface: (name: string) => void
|
||||
dedup: boolean
|
||||
onDedup: (value: boolean) => void
|
||||
excludeMesh: boolean
|
||||
onExcludeMesh: (value: boolean) => void
|
||||
excludeOverlay: boolean
|
||||
onExcludeOverlay: (value: boolean) => void
|
||||
liveHint?: string
|
||||
emptyHint?: string
|
||||
}) {
|
||||
@@ -276,6 +381,26 @@ export function FlowAnalyticsDetail({
|
||||
Без дублей
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="flow-exclude-overlay"
|
||||
checked={excludeOverlay}
|
||||
onCheckedChange={onExcludeOverlay}
|
||||
/>
|
||||
<Label htmlFor="flow-exclude-overlay" className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Без overlay
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="flow-exclude-mesh"
|
||||
checked={excludeMesh}
|
||||
onCheckedChange={onExcludeMesh}
|
||||
/>
|
||||
<Label htmlFor="flow-exclude-mesh" className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Без mesh
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{RANGE_KEYS.map((r) => (
|
||||
<button
|
||||
@@ -344,7 +469,7 @@ export function FlowAnalyticsDetail({
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<KpiStatGrid
|
||||
aria-label="Скорость потоков"
|
||||
items={[
|
||||
items={([
|
||||
{
|
||||
id: "bps-now",
|
||||
label: "Скорость сейчас",
|
||||
@@ -355,11 +480,38 @@ export function FlowAnalyticsDetail({
|
||||
},
|
||||
{
|
||||
id: "bytes",
|
||||
label: "Байт за период",
|
||||
label: "Payload",
|
||||
value: formatBytes(bytes),
|
||||
hint: "inner IPFIX",
|
||||
icon: <ArrowUpIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "overlay",
|
||||
label: "JH↔EN overlay",
|
||||
value: fmtRate((analytics?.bpsOverlay ?? 0) / 1_000_000),
|
||||
hint: analytics?.bytesOverlay ? formatBytes(analytics.bytesOverlay) : undefined,
|
||||
icon: <ShieldIcon className="size-4" />,
|
||||
iconClassName: "text-warning",
|
||||
},
|
||||
{
|
||||
id: "wire",
|
||||
label: "Wire GRE",
|
||||
value: fmtRate((analytics?.bpsWire ?? 0) / 1_000_000),
|
||||
hint: "счётчик iface",
|
||||
icon: <RouteIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
...(!excludeMesh
|
||||
? [{
|
||||
id: "mesh",
|
||||
label: "Mesh",
|
||||
value: formatBytes(analytics?.bytesMesh ?? 0),
|
||||
hint: "клиент↔клиент",
|
||||
icon: <NetworkIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
id: "flows",
|
||||
label: "Сессии",
|
||||
@@ -384,7 +536,7 @@ export function FlowAnalyticsDetail({
|
||||
icon: <LayersIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
]}
|
||||
] satisfies KpiStatItem[])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -404,6 +556,7 @@ export function FlowAnalyticsDetail({
|
||||
<TabsTrigger value="protocols">Протоколы</TabsTrigger>
|
||||
<TabsTrigger value="sources">Источники</TabsTrigger>
|
||||
<TabsTrigger value="destinations">Назначения</TabsTrigger>
|
||||
<TabsTrigger value="paths">Пути</TabsTrigger>
|
||||
<TabsTrigger value="sessions">Сессии</TabsTrigger>
|
||||
<TabsTrigger value="interfaces">Интерфейсы</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -440,6 +593,20 @@ export function FlowAnalyticsDetail({
|
||||
<TabsContent value="destinations">
|
||||
<FlowBreakdownGrid rows={analytics?.destinations ?? []} onPick={(row) => pickBreakdown("destination", row)} />
|
||||
</TabsContent>
|
||||
<TabsContent value="paths">
|
||||
<FlowPathsGrid
|
||||
rows={analytics?.paths ?? []}
|
||||
empty="Нет путей за период"
|
||||
onPick={(row) => {
|
||||
setSessionFilter({
|
||||
kind: "client",
|
||||
value: row.clientId,
|
||||
label: `${row.clientName} → ${row.enName || row.dst}`,
|
||||
})
|
||||
setSlice("sessions")
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="sessions">
|
||||
{sessionFilter ? (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import type { FlowPurgeDto } from "@mmapp/contracts/traffic-flow"
|
||||
import { AlertCircleIcon, LoaderCircleIcon } from "lucide-react"
|
||||
|
||||
function formatDbFileBytes(n: number): string {
|
||||
if (n < 1024) return `${n} Б`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} КБ`
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} МБ`
|
||||
}
|
||||
|
||||
export function formatFlowPurgeResult(result: FlowPurgeDto): string {
|
||||
const rows =
|
||||
result.deleted.buckets +
|
||||
result.deleted.minuteStats +
|
||||
result.deleted.minuteDims +
|
||||
result.deleted.dailyDims
|
||||
const vacuumHint = result.vacuumed ? "" : " VACUUM не выполнен."
|
||||
return `Удалено строк: ${rows}. Файл ${formatDbFileBytes(result.fileBytesBefore)} → ${formatDbFileBytes(result.fileBytesAfter)}.${vacuumHint}`
|
||||
}
|
||||
|
||||
export function NetflowPurgeConfirm({
|
||||
open,
|
||||
busy,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
open: boolean
|
||||
busy?: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
|
||||
<AlertDialogContent size="default">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||
<AlertCircleIcon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Сбросить данные NetFlow?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Будут удалены сессии и агрегаты (minute/daily) из SQLite, затем VACUUM.
|
||||
Ключи WireGuard, пиры JH, настройки коллектора и кэш RIPE сохранятся.
|
||||
На время операции приём IPFIX остановится.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={busy}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
||||
{busy ? "Сброс…" : "Сбросить"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
generateTrafficFlowKeys,
|
||||
getTrafficFlowHostFiles,
|
||||
getTrafficFlowSettings,
|
||||
purgeTrafficFlowData,
|
||||
putTrafficFlowSettings,
|
||||
} from "@/shared/api/traffic-flow"
|
||||
import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic/netflow-purge-dialog"
|
||||
import { KeyRoundIcon, DownloadIcon, InfoIcon } from "lucide-react"
|
||||
|
||||
const HOST_STEPS = [
|
||||
@@ -46,6 +48,8 @@ function NetflowSettingsPanel({
|
||||
const [retention, setRetention] = useState("24")
|
||||
const [topN, setTopN] = useState("200")
|
||||
const [ingestOn, setIngestOn] = useState(false)
|
||||
const [purgeOpen, setPurgeOpen] = useState(false)
|
||||
const [purgeBusy, setPurgeBusy] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!enabled) return
|
||||
@@ -102,6 +106,20 @@ function NetflowSettingsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePurgeConfirm() {
|
||||
setPurgeBusy(true)
|
||||
try {
|
||||
const result = await purgeTrafficFlowData(backendUrl)
|
||||
toast.success(formatFlowPurgeResult(result))
|
||||
setPurgeOpen(false)
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Не удалось сбросить NetFlow")
|
||||
} finally {
|
||||
setPurgeBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
setBusy(true)
|
||||
try {
|
||||
@@ -196,18 +214,35 @@ function NetflowSettingsPanel({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" disabled={busy} onClick={() => { void handleSave() }}>
|
||||
<Button size="sm" disabled={busy || purgeBusy} onClick={() => { void handleSave() }}>
|
||||
Сохранить NetFlow
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => { void handleKeys() }}>
|
||||
<Button size="sm" variant="outline" disabled={busy || purgeBusy} onClick={() => { void handleKeys() }}>
|
||||
<KeyRoundIcon className="size-4" />
|
||||
Ключи хоста
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => { void handleExport() }}>
|
||||
<Button size="sm" variant="outline" disabled={busy || purgeBusy} onClick={() => { void handleExport() }}>
|
||||
<DownloadIcon className="size-4" />
|
||||
wg-quick / compose / firewall
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border border-destructive/30 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Сбросить данные NetFlow</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Удалит сессии и агрегаты из SQLite, затем VACUUM. Ключи WG и пиры не трогает.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={busy || purgeBusy}
|
||||
onClick={() => setPurgeOpen(true)}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
</div>
|
||||
</OpsPanel>
|
||||
|
||||
<CodeExportSheet
|
||||
@@ -217,6 +252,13 @@ function NetflowSettingsPanel({
|
||||
description="wg-quick, фрагмент compose и firewall. Хост, не контейнер backend."
|
||||
formats={formats}
|
||||
/>
|
||||
|
||||
<NetflowPurgeConfirm
|
||||
open={purgeOpen}
|
||||
busy={purgeBusy}
|
||||
onConfirm={() => { void handlePurgeConfirm() }}
|
||||
onCancel={() => { if (!purgeBusy) setPurgeOpen(false) }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user