fix(ui): заменить таблицы на карточки данных и улучшить функциональность поиска
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 1m50s
Docker images / updater-image (push) Successful in 44s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 1m50s
Docker images / updater-image (push) Successful in 44s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
This commit is contained in:
+27
-50
@@ -2,11 +2,13 @@
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { AsnsDataGrid } from "@/components/data-grids/asns-data-grid"
|
||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { asns as mockAsns } from "@/lib/data"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -16,6 +18,7 @@ export default function AsnsPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
|
||||
@@ -25,6 +28,17 @@ export default function AsnsPage() {
|
||||
return snapshot?.asns ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return rows
|
||||
const q = search.toLowerCase()
|
||||
return rows.filter(
|
||||
(r) =>
|
||||
r.asn.toLowerCase().includes(q) ||
|
||||
r.org.toLowerCase().includes(q) ||
|
||||
String(r.prefixes).includes(q),
|
||||
)
|
||||
}, [rows, search])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
@@ -60,56 +74,19 @@ export default function AsnsPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
data={rows}
|
||||
isLoading={useEvoCatalog && loading && !snapshot}
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по ASN, имени, префиксам…"
|
||||
searchKeys={["asn", "org", "prefixes"]}
|
||||
columns={[
|
||||
{
|
||||
key: "asn",
|
||||
label: "ASN",
|
||||
render: (d) => <span className="font-mono font-semibold">{d.asn}</span>,
|
||||
},
|
||||
{
|
||||
key: "org",
|
||||
label: "Имя / организация",
|
||||
render: (d) => (
|
||||
<span className="font-medium max-w-[min(28rem,50vw)] truncate block" title={d.org}>
|
||||
{d.org}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "prefixes",
|
||||
label: "Префиксов",
|
||||
render: (d) => <span className="font-mono tabular-nums">{d.prefixes.toLocaleString("ru")}</span>,
|
||||
},
|
||||
{
|
||||
key: "filter",
|
||||
label: "Фильтр",
|
||||
render: (d) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updated",
|
||||
label: "Обновлён",
|
||||
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
|
||||
},
|
||||
{
|
||||
key: "enabled",
|
||||
label: "Статус",
|
||||
render: (d) => (
|
||||
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
|
||||
{d.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
countLabel={`${filtered.length} ASN`}
|
||||
/>
|
||||
<AsnsDataGrid
|
||||
asns={filtered}
|
||||
isLoading={useEvoCatalog && loading && !snapshot}
|
||||
pagination={useEvoCatalog}
|
||||
/>
|
||||
</DataPageCard>
|
||||
</div>
|
||||
</div>
|
||||
<FileImportDialog
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/compone
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import type { Backup, Server } from "@/lib/data"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
@@ -396,7 +397,7 @@ export default function BackupsPage() {
|
||||
|
||||
{/* ── История ──────────────────────────────────────────────────── */}
|
||||
{tab === "history" && (
|
||||
<Card>
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
segmented={{
|
||||
value: kindFilter,
|
||||
@@ -418,7 +419,7 @@ export default function BackupsPage() {
|
||||
}}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</Card>
|
||||
</DataPageCard>
|
||||
)}
|
||||
|
||||
{/* ── Настройки ────────────────────────────────────────────────── */}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
|
||||
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
|
||||
import { BGP_AS_NAMES } from "@/lib/bgp/types"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
@@ -292,7 +293,7 @@ function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="overflow-hidden">
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
segmented={{
|
||||
value: stateFilter,
|
||||
@@ -333,7 +334,7 @@ function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
}
|
||||
/>
|
||||
<BgpSessionsDataGrid sessions={filtered} />
|
||||
</Card>
|
||||
</DataPageCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { routerCertificates, servers as mockServers } from "@/lib/data"
|
||||
import type { CertStatus, Server } from "@/lib/data"
|
||||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
@@ -37,17 +38,10 @@ import { toast } from "sonner"
|
||||
import {
|
||||
SearchIcon,
|
||||
ShieldCheckIcon,
|
||||
ShieldAlertIcon,
|
||||
ShieldOffIcon,
|
||||
BadgeCheckIcon,
|
||||
AlertTriangleIcon,
|
||||
AlertCircleIcon,
|
||||
CalendarIcon,
|
||||
KeyRoundIcon,
|
||||
ServerIcon,
|
||||
PlusIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
RefreshCwIcon,
|
||||
UploadIcon,
|
||||
} from "lucide-react"
|
||||
@@ -63,50 +57,6 @@ import {
|
||||
StepperTrigger,
|
||||
} from "@/components/reui/stepper"
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
CertStatus,
|
||||
{
|
||||
label: string
|
||||
icon: ReactNode
|
||||
badge: string
|
||||
row: string
|
||||
}
|
||||
> = {
|
||||
valid: {
|
||||
label: "Действителен",
|
||||
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />,
|
||||
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
||||
row: "",
|
||||
},
|
||||
expired: {
|
||||
label: "Истёк",
|
||||
icon: <ShieldOffIcon className="size-4 text-red-500" />,
|
||||
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||
row: "bg-red-500/5",
|
||||
},
|
||||
revoked: {
|
||||
label: "Отозван",
|
||||
icon: <ShieldAlertIcon className="size-4 text-amber-500" />,
|
||||
badge: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||||
row: "bg-amber-500/5",
|
||||
},
|
||||
}
|
||||
|
||||
const CERT_TABLE_GRID_CLASS =
|
||||
"grid grid-cols-[1.25rem_minmax(0,1.35fr)_minmax(0,0.85fr)_minmax(0,1fr)_9.5rem_minmax(0,8.5rem)_6rem] gap-3"
|
||||
|
||||
function daysLeftColor(days: number): string {
|
||||
if (days < 0) return "text-red-500"
|
||||
if (days <= 7) return "text-red-500"
|
||||
if (days <= 30) return "text-amber-500"
|
||||
return "text-emerald-600 dark:text-emerald-400"
|
||||
}
|
||||
|
||||
function daysLeftBar(days: number, total = 365): number {
|
||||
if (days <= 0) return 0
|
||||
return Math.min(100, Math.round((days / total) * 100))
|
||||
}
|
||||
|
||||
function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
|
||||
return {
|
||||
id: cert.id,
|
||||
@@ -125,203 +75,6 @@ function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
|
||||
}
|
||||
}
|
||||
|
||||
function CertPartDaysBar({ cert, pct }: { cert: CertificateDto; pct: number }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
cert.daysLeft < 0
|
||||
? "bg-red-500"
|
||||
: cert.daysLeft <= 7
|
||||
? "bg-red-500"
|
||||
: cert.daysLeft <= 30
|
||||
? "bg-amber-500"
|
||||
: "bg-emerald-500",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CertPartDays({ cert, pct }: { cert: CertificateDto; pct: number }) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className={cn("font-mono font-medium", daysLeftColor(cert.daysLeft))}>
|
||||
{cert.daysLeft < 0 ? `Истёк ${-cert.daysLeft}д назад` : `${cert.daysLeft}д осталось`}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">{cert.validUntil}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<CertPartDaysBar cert={cert} pct={pct} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CertPartDetailDates({ cert }: { cert: CertificateDto }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Действителен с</p>
|
||||
<p className="font-mono">{cert.validFrom}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CertPartDetailSans({ cert }: { cert: CertificateDto }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">SAN / Alt Names</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{cert.sans.length > 0
|
||||
? cert.sans.map((s) => (
|
||||
<span key={s} className="font-mono bg-muted px-1.5 py-0.5 rounded">
|
||||
{s}
|
||||
</span>
|
||||
))
|
||||
: <span className="text-muted-foreground">—</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CertPartDetailTrusted({ cert }: { cert: CertificateDto }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Trusted</p>
|
||||
<p className={cert.trusted ? "text-emerald-600 dark:text-emerald-400" : "text-red-500"}>
|
||||
{cert.trusted ? "Да (доверенный)" : "Нет (не доверенный)"}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CertListRow({
|
||||
cert,
|
||||
cfg,
|
||||
pct,
|
||||
server,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
cert: CertificateDto
|
||||
cfg: (typeof STATUS_CONFIG)[CertStatus]
|
||||
pct: number
|
||||
server?: Server
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("border-b last:border-b-0", cfg.row)}>
|
||||
<div
|
||||
className={cn(
|
||||
CERT_TABLE_GRID_CLASS,
|
||||
"px-4 py-3 items-center hover:bg-muted/30 transition-colors cursor-pointer",
|
||||
)}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggle()
|
||||
}}
|
||||
>
|
||||
{expanded ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="shrink-0">{cfg.icon}</span>
|
||||
<span className="font-medium text-sm truncate" title={cert.name}>
|
||||
{cert.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono mt-0.5 truncate" title={cert.commonName}>
|
||||
{cert.commonName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
|
||||
{server
|
||||
? (
|
||||
<>
|
||||
<Flag code={server.country} size={12} />
|
||||
<span className="font-mono truncate">{server.name}</span>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<ServerIcon className="size-3.5" />
|
||||
<span className="font-mono truncate">{cert.serverName ?? cert.serverId}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate min-w-0" title={cert.issuedBy}>
|
||||
{cert.issuedBy}
|
||||
</p>
|
||||
<div className="min-w-0">
|
||||
<CertPartDays cert={cert} pct={pct} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 min-w-0 overflow-hidden">
|
||||
{cert.usage.map((u) => (
|
||||
<span
|
||||
key={u}
|
||||
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground border"
|
||||
>
|
||||
{u}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap shrink-0 justify-self-end",
|
||||
cfg.badge,
|
||||
)}
|
||||
>
|
||||
{cfg.label}
|
||||
</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="px-10 pb-4 grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs border-t border-border/50 pt-3">
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Key size</p>
|
||||
<p className="font-mono font-medium">{cert.keySize} bit</p>
|
||||
</div>
|
||||
<CertPartDetailDates cert={cert} />
|
||||
<CertPartDetailSans cert={cert} />
|
||||
<CertPartDetailTrusted cert={cert} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CertRow({
|
||||
cert,
|
||||
server,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
cert: CertificateDto
|
||||
server?: Server
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const cfg = STATUS_CONFIG[cert.status]
|
||||
const pct = daysLeftBar(cert.daysLeft)
|
||||
|
||||
return (
|
||||
<CertListRow
|
||||
cert={cert}
|
||||
cfg={cfg}
|
||||
pct={pct}
|
||||
server={server}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CertPartAlertExpired({ expired }: { expired: CertificateDto[] }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-red-500/5 border border-red-500/20 px-4 py-3 text-sm">
|
||||
@@ -536,43 +289,6 @@ function CertPartTableToolbar({
|
||||
)
|
||||
}
|
||||
|
||||
function CertPartTableHeaderDates() {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<CalendarIcon className="size-3" />
|
||||
Срок
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CertPartTableHeaderUsage() {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<KeyRoundIcon className="size-3" />
|
||||
Использование
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CertPartTableHeader() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
CERT_TABLE_GRID_CLASS,
|
||||
"px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20",
|
||||
)}
|
||||
>
|
||||
<span />
|
||||
<span>Имя / CN</span>
|
||||
<span>Сервер</span>
|
||||
<span>Выпущен</span>
|
||||
<CertPartTableHeaderDates />
|
||||
<CertPartTableHeaderUsage />
|
||||
<span>Статус</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CertPartReference() {
|
||||
return (
|
||||
<Card>
|
||||
@@ -757,7 +473,6 @@ export default function CertificatesPage() {
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<CertStatus | "all">("all")
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||
const [certificates, setCertificates] = useState<CertificateDto[]>([])
|
||||
const [loadState, setLoadState] = useState<"idle" | "loading" | "error">("idle")
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
@@ -871,15 +586,6 @@ export default function CertificatesPage() {
|
||||
})
|
||||
}, [displayCerts, search, statusFilter])
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
if (!liveReady) return
|
||||
try {
|
||||
@@ -1040,7 +746,7 @@ export default function CertificatesPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<DataPageCard>
|
||||
<CertPartTableToolbar
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
@@ -1048,30 +754,12 @@ export default function CertificatesPage() {
|
||||
setStatusFilter={setStatusFilter}
|
||||
filteredCount={filtered.length}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[48rem]">
|
||||
<CertPartTableHeader />
|
||||
{prefsHydrated && isLive && loadState === "loading" && displayCerts.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-muted-foreground">Загрузка сертификатов…</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||||
<ShieldCheckIcon className="size-10 mb-3 opacity-20" />
|
||||
<p className="text-sm font-medium">Сертификаты не найдены</p>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((cert) => (
|
||||
<CertRow
|
||||
key={cert.id}
|
||||
cert={cert}
|
||||
server={serverById.get(cert.serverId)}
|
||||
expanded={expandedIds.has(cert.id)}
|
||||
onToggle={() => toggleExpand(cert.id)}
|
||||
<CertificatesDataGrid
|
||||
certificates={filtered}
|
||||
serverMap={serverById}
|
||||
isLoading={prefsHydrated && isLive && loadState === "loading" && displayCerts.length === 0}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</DataPageCard>
|
||||
|
||||
<CertPartReference />
|
||||
</div>
|
||||
|
||||
+21
-117
@@ -2,14 +2,22 @@
|
||||
|
||||
import { useState, useMemo, useEffect } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import {
|
||||
CommunitiesDataGrid,
|
||||
type CommunityRow,
|
||||
TYPE_LABELS,
|
||||
ACTION_LABELS,
|
||||
ACTION_COLOR,
|
||||
} from "@/components/data-grids/communities-data-grid"
|
||||
import {
|
||||
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
||||
} from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
PlusIcon, SearchIcon, TagIcon, ServerIcon, FilterIcon,
|
||||
ChevronRightIcon, CopyIcon, CheckIcon, TrashIcon, PencilIcon,
|
||||
PlusIcon, SearchIcon, TagIcon, FilterIcon,
|
||||
CopyIcon, CheckIcon, TrashIcon, PencilIcon,
|
||||
LoaderCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -19,21 +27,8 @@ import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type CommType = "standard" | "no-export" | "no-advertise" | "local-as" | "custom"
|
||||
|
||||
interface Community {
|
||||
id: string
|
||||
value: string // e.g. "65001:100"
|
||||
name: string
|
||||
description: string
|
||||
type: CommType
|
||||
filterIds: string[] // which filters use this community
|
||||
serverCount: number
|
||||
prefixCount: number
|
||||
action: "permit" | "deny" | "local-pref" | "metric"
|
||||
actionValue?: number // e.g. local-pref value
|
||||
enabled: boolean
|
||||
}
|
||||
type Community = CommunityRow
|
||||
type CommType = CommunityRow["type"]
|
||||
|
||||
// ─── mock data ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -100,28 +95,6 @@ const COMMUNITIES: Community[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const TYPE_LABELS: Record<CommType, string> = {
|
||||
"standard": "Стандартный",
|
||||
"no-export": "No-export",
|
||||
"no-advertise":"No-advertise",
|
||||
"local-as": "Local-AS",
|
||||
"custom": "Кастомный",
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<Community["action"], string> = {
|
||||
"permit": "Permit",
|
||||
"deny": "Deny",
|
||||
"local-pref": "Local-pref",
|
||||
"metric": "MED/Metric",
|
||||
}
|
||||
|
||||
const ACTION_COLOR: Record<Community["action"], string> = {
|
||||
"permit": "text-emerald-500",
|
||||
"deny": "text-red-500",
|
||||
"local-pref": "text-blue-500",
|
||||
"metric": "text-amber-500",
|
||||
}
|
||||
|
||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CommunitiesPage() {
|
||||
@@ -225,84 +198,15 @@ export default function CommunitiesPage() {
|
||||
</div>
|
||||
|
||||
{/* list */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-muted-foreground">
|
||||
<th className="text-left font-medium px-4 py-2.5">Community</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Имя / описание</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Тип</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Действие</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">Маршрутов</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">Серверов</th>
|
||||
<th className="px-4 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(c => (
|
||||
<tr
|
||||
key={c.id}
|
||||
onClick={() => setSelected(c)}
|
||||
className={cn(
|
||||
"border-b last:border-0 cursor-pointer hover:bg-muted/40 transition-colors",
|
||||
selected?.id === c.id && "bg-primary/5",
|
||||
!c.enabled && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<TagIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-xs font-medium bg-muted px-1.5 py-0.5 rounded">
|
||||
{c.value}
|
||||
</span>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleCopy(c.value) }}
|
||||
className="text-muted-foreground/40 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
{copied === c.value
|
||||
? <CheckIcon className="size-3" />
|
||||
: <CopyIcon className="size-3" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<p className="font-medium text-xs">{c.name}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">{c.description}</p>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="text-xs text-muted-foreground">{TYPE_LABELS[c.type]}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={cn("text-xs font-medium", ACTION_COLOR[c.action])}>
|
||||
{ACTION_LABELS[c.action]}{c.actionValue !== undefined ? ` ${c.actionValue}` : ""}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-xs tabular-nums">
|
||||
{c.prefixCount.toLocaleString("ru-RU")}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<ServerIcon className="size-3 text-muted-foreground" />
|
||||
<span className="font-mono text-xs">{c.serverCount.toLocaleString("ru-RU")}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<ChevronRightIcon className="size-4 text-muted-foreground/40" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filtered.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
|
||||
<TagIcon className="size-8 opacity-30" />
|
||||
<p className="text-sm">Ничего не найдено</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<DataPageCard>
|
||||
<CommunitiesDataGrid
|
||||
communities={filtered}
|
||||
selectedId={selected?.id}
|
||||
copiedValue={copied}
|
||||
onSelect={setSelected}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
</DataPageCard>
|
||||
</div>
|
||||
|
||||
{/* ── detail panel ── */}
|
||||
|
||||
+14
-128
@@ -22,8 +22,10 @@ import type { PingProbe, Server, ServerStatus, ServerType } from "@/lib/data"
|
||||
import type { GreTunnel } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, FilterIcon, DownloadIcon } from "lucide-react"
|
||||
import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, DownloadIcon } from "lucide-react"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DashboardActiveProbesDataGrid } from "@/components/data-grids/dashboard-active-probes-data-grid"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency"
|
||||
@@ -72,11 +74,6 @@ function StatCard({
|
||||
)
|
||||
}
|
||||
|
||||
function formatLossPct(loss: number): string {
|
||||
if (!Number.isFinite(loss)) return "—"
|
||||
return Number.isInteger(loss) ? `${loss}%` : `${loss.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function fmtIntRu(n: number): string {
|
||||
return n.toLocaleString("ru-RU")
|
||||
}
|
||||
@@ -114,22 +111,6 @@ function readMockDashboardStarIds(): Set<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Совпадает с эталоном uptime / servers */
|
||||
function TypeChip({ type }: { type: ServerType }) {
|
||||
return (
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
|
||||
type === "home-router"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: type === "jump-host"
|
||||
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
|
||||
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
)}>
|
||||
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface BackendServerRow {
|
||||
id: number
|
||||
name: string
|
||||
@@ -241,52 +222,6 @@ function mapBackendToServer(s: BackendServerRow): Server {
|
||||
}
|
||||
}
|
||||
|
||||
function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server[] }) {
|
||||
const srv = catalog.find(s => s.id === probe.srcServerId)
|
||||
const iface = (probe.srcInterface ?? "").trim() || "auto"
|
||||
|
||||
if (!srv) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
|
||||
<span className="mt-1 shrink-0 inline-flex">
|
||||
<StatusDot status="offline" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-medium text-muted-foreground truncate">
|
||||
Сервер <span className="font-mono tabular-nums">{probe.srcServerId}</span>
|
||||
</p>
|
||||
<p className="text-[11px] font-mono text-muted-foreground truncate mt-0.5" title={iface}>
|
||||
{iface}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
|
||||
<span className="mt-1 shrink-0 inline-flex">
|
||||
<StatusDot status={srv.status} pulse={srv.status === "online"} />
|
||||
</span>
|
||||
<div className="flex gap-2 min-w-0 flex-1">
|
||||
<Flag code={srv.country} size={16} className="shrink-0 mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[13px] font-medium leading-tight truncate">{srv.name}</span>
|
||||
<TypeChip type={srv.type} />
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate" title={`Интерфейс: ${iface}`}>
|
||||
<span className="font-mono tabular-nums">{iface}</span>
|
||||
{srv.site && srv.site !== "—" && (
|
||||
<span className="text-muted-foreground/90"> · {srv.site}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const pathname = usePathname()
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
@@ -1020,67 +955,18 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 px-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||
<th className="text-left font-medium px-5 py-2.5 w-[min(280px,32vw)]">Источник</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Проба</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Цель</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Фильтр</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">RTT</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">Потери</th>
|
||||
<th className="text-left font-medium px-4 py-2.5 w-36">60с</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{isLive && probesLoading && liveProbes === null && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-5 py-6">
|
||||
<div className="h-10 rounded-md bg-muted/50 animate-pulse max-w-md mx-auto" />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!(isLive && probesLoading && liveProbes === null) && activeProbesTable.map((p) => {
|
||||
const sparkColor = p.status === "down" ? "hsl(0 84% 60%)" : p.status === "warn" ? "hsl(32 94% 44%)" : "hsl(142 76% 36%)"
|
||||
return (
|
||||
<tr key={p.id} className="hover:bg-muted/40 transition-colors">
|
||||
<td className="px-5 py-2.5 align-top">
|
||||
<ProbeSourceCell probe={p} catalog={probeServerCatalog} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-medium">{p.name}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{p.target}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="inline-flex items-center gap-1 text-xs border border-border rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />{p.filter}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-right">{p.rtt == null ? "—" : `${p.rtt} мс`}</td>
|
||||
<td className={`px-4 py-2.5 font-mono text-right ${p.loss > 5 ? "text-red-500" : p.loss > 0 ? "text-amber-500" : "text-muted-foreground"}`}>
|
||||
{formatLossPct(p.loss)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Sparkline data={p.series} width={120} height={24} color={sparkColor} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<StatusBadge status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"} />
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{!(isLive && probesLoading && liveProbes === null) && activeProbesTable.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-5 py-8 text-center text-sm text-muted-foreground">
|
||||
{isLive && probesError
|
||||
<DataPageCard className="rounded-none border-0 shadow-none">
|
||||
<DashboardActiveProbesDataGrid
|
||||
probes={activeProbesTable}
|
||||
catalog={probeServerCatalog}
|
||||
isLoading={isLive && probesLoading && liveProbes === null}
|
||||
emptyDescription={
|
||||
isLive && probesError
|
||||
? "Нет данных о пробах. Проверьте сборщик uptime и настройки проб на странице мониторинга."
|
||||
: "Нет проб с звездой на дашборде. Включите пробу и отметьте ★ в разделе «Мониторинг»."}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
: "Нет проб с звездой на дашборде. Включите пробу и отметьте ★ в разделе «Мониторинг»."
|
||||
}
|
||||
/>
|
||||
</DataPageCard>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
+156
-460
@@ -28,7 +28,6 @@ import {
|
||||
import { requestJson, ApiClientError } from "@/shared/api/http-client"
|
||||
import {
|
||||
parseSchedulerRunSnapshot,
|
||||
type AlertEngineRuleDiagSnapshot,
|
||||
type AlertEngineRunSnapshot,
|
||||
type GreBgpSnapshotRunSnapshot,
|
||||
type InternetPathRunSnapshot,
|
||||
@@ -41,6 +40,19 @@ import {
|
||||
type SpeedScheduledRunSnapshot,
|
||||
type TrafficRunSnapshot,
|
||||
} from "@/lib/scheduler-run-snapshot"
|
||||
import {
|
||||
AlertEngineRuleDiagGrid,
|
||||
PingSnapshotGrid,
|
||||
ResourcesSnapshotGrid,
|
||||
ServersRestPingSnapshotGrid,
|
||||
SpeedSnapshotGrid,
|
||||
TrafficSnapshotGrid,
|
||||
} from "@/components/data-grids/snapshot-data-grid"
|
||||
import {
|
||||
DataCollectionSchedulerDataGrid,
|
||||
type SchedulerJobGridRow,
|
||||
} from "@/components/data-grids/data-collection-scheduler-data-grid"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
@@ -86,16 +98,6 @@ function fmtMs(ms: number): string {
|
||||
return s < 60 ? `${s.toFixed(1)} с` : `${Math.floor(s / 60)} м ${Math.round(s % 60)} с`
|
||||
}
|
||||
|
||||
function fmtUptimeSec(sec: number): string {
|
||||
if (sec <= 0) return "—"
|
||||
const d = Math.floor(sec / 86400)
|
||||
const h = Math.floor((sec % 86400) / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
if (d > 0) return `${d}д ${h}ч`
|
||||
if (h > 0) return `${h}ч ${m}м`
|
||||
return `${m}м`
|
||||
}
|
||||
|
||||
function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "traffic") {
|
||||
const t = snap as TrafficRunSnapshot
|
||||
@@ -113,44 +115,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Сэмплы на момент <span className="font-mono tabular-nums">{new Date(t.sampledAt).toLocaleString("ru-RU")}</span>
|
||||
</p>
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-muted-foreground text-left">
|
||||
<th className="px-3 py-2 font-medium">Сервер</th>
|
||||
<th className="px-3 py-2 font-medium">Хост</th>
|
||||
<th className="px-3 py-2 font-medium">Результат</th>
|
||||
<th className="px-3 py-2 font-medium text-right">IF</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Σ RX</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Σ TX</th>
|
||||
<th className="px-3 py-2 font-medium">Ошибка</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{t.servers.map((s) => (
|
||||
<tr key={s.serverId} className="hover:bg-muted/30">
|
||||
<td className="px-3 py-2 font-medium">{s.name}</td>
|
||||
<td className="px-3 py-2 font-mono text-muted-foreground">{s.host}</td>
|
||||
<td className="px-3 py-2">
|
||||
{s.ok ? (
|
||||
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400">
|
||||
ok
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive">
|
||||
ошибка
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{s.interfaces ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{s.sumRxMbps != null ? `${s.sumRxMbps} Мбит/с` : "—"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{s.sumTxMbps != null ? `${s.sumTxMbps} Мбит/с` : "—"}</td>
|
||||
<td className="px-3 py-2 text-destructive max-w-[220px] truncate" title={s.error}>{s.error ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataPageCard>
|
||||
<TrafficSnapshotGrid servers={t.servers} />
|
||||
</DataPageCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -170,59 +137,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Сэмплы на <span className="font-mono tabular-nums">{new Date(u.sampledAt).toLocaleString("ru-RU")}</span>
|
||||
</p>
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-muted-foreground text-left">
|
||||
<th className="px-3 py-2 font-medium">Сервер</th>
|
||||
<th className="px-3 py-2 font-medium">Статус</th>
|
||||
<th className="px-3 py-2 font-medium text-right">CPU %</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Память</th>
|
||||
<th className="px-3 py-2 font-medium text-right">% RAM</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Диск своб.</th>
|
||||
<th className="px-3 py-2 font-medium">Uptime</th>
|
||||
<th className="px-3 py-2 font-medium">Плата / ROS</th>
|
||||
<th className="px-3 py-2 font-medium">Ошибка</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{u.servers.map((s) => (
|
||||
<tr key={s.serverId} className="hover:bg-muted/30">
|
||||
<td className="px-3 py-2">
|
||||
<span className="font-medium">{s.name}</span>
|
||||
<span className="block font-mono text-[10px] text-muted-foreground">{s.host}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
s.status === "online" && "border-emerald-500/40 text-emerald-700 dark:text-emerald-400",
|
||||
s.status === "offline" && "border-destructive/50 text-destructive",
|
||||
)}
|
||||
>
|
||||
{s.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{s.cpuLoadPct ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums whitespace-nowrap">
|
||||
{s.memUsedMb != null && s.memTotalMb != null ? `${s.memUsedMb} / ${s.memTotalMb} МБ` : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{s.memUsedPct != null ? `${s.memUsedPct}%` : "—"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums whitespace-nowrap">
|
||||
{s.diskFreeMb != null && s.diskTotalMb != null ? `${s.diskFreeMb} / ${s.diskTotalMb} МБ` : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 tabular-nums">{s.uptimeSeconds != null ? fmtUptimeSec(s.uptimeSeconds) : "—"}</td>
|
||||
<td className="px-3 py-2 max-w-[140px]">
|
||||
<span className="block truncate" title={s.boardName}>{s.boardName || "—"}</span>
|
||||
<span className="block truncate text-muted-foreground font-mono text-[10px]" title={s.rosVersion}>{s.rosVersion || ""}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-destructive max-w-[160px] truncate" title={s.error}>{s.error ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataPageCard>
|
||||
<ResourcesSnapshotGrid servers={u.servers} />
|
||||
</DataPageCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -243,42 +160,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
GET <span className="font-mono">/system/identity</span> на{" "}
|
||||
<span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span>
|
||||
</p>
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-muted-foreground text-left">
|
||||
<th className="px-3 py-2 font-medium">Сервер</th>
|
||||
<th className="px-3 py-2 font-medium">Хост</th>
|
||||
<th className="px-3 py-2 font-medium">Результат</th>
|
||||
<th className="px-3 py-2 font-medium text-right">RTT REST</th>
|
||||
<th className="px-3 py-2 font-medium">Ошибка</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{s.servers.map((row) => (
|
||||
<tr key={row.serverId} className="hover:bg-muted/30">
|
||||
<td className="px-3 py-2 font-medium">{row.name}</td>
|
||||
<td className="px-3 py-2 font-mono text-muted-foreground">{row.host}</td>
|
||||
<td className="px-3 py-2">
|
||||
{row.ok ? (
|
||||
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400">
|
||||
ok
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive">
|
||||
недоступен
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{row.latencyMs != null ? `${row.latencyMs} мс` : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-destructive max-w-[220px] truncate" title={row.error}>{row.error ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataPageCard>
|
||||
<ServersRestPingSnapshotGrid servers={s.servers} />
|
||||
</DataPageCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -301,41 +185,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Сэмплы на <span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span> — только пробы, для которых записан замер в этом тике
|
||||
</p>
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-muted-foreground text-left">
|
||||
<th className="px-3 py-2 font-medium">Проба</th>
|
||||
<th className="px-3 py-2 font-medium">Цель</th>
|
||||
<th className="px-3 py-2 font-medium">Источник</th>
|
||||
<th className="px-3 py-2 font-medium">IF</th>
|
||||
<th className="px-3 py-2 font-medium text-right">RTT</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Loss</th>
|
||||
<th className="px-3 py-2 font-medium">Статус</th>
|
||||
<th className="px-3 py-2 font-medium">Ошибка</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{p.probes.map((x) => (
|
||||
<tr key={`${x.probeId}-${x.target}`} className="hover:bg-muted/30">
|
||||
<td className="px-3 py-2">
|
||||
<span className="font-medium">{x.name}</span>
|
||||
<span className="block font-mono text-[10px] text-muted-foreground">{x.probeId}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono">{x.target}</td>
|
||||
<td className="px-3 py-2">{x.srcServerName}</td>
|
||||
<td className="px-3 py-2 font-mono text-muted-foreground">{x.srcInterface || "—"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{x.rttMs != null ? `${x.rttMs} мс` : "—"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{x.lossPct}%</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant="outline" className="text-[10px]">{x.status}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-destructive max-w-[180px] truncate" title={x.error}>{x.error ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataPageCard>
|
||||
<PingSnapshotGrid probes={p.probes} />
|
||||
</DataPageCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -346,56 +198,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> — по очереди для каждой включённой пробы
|
||||
</p>
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-muted-foreground text-left">
|
||||
<th className="px-3 py-2 font-medium">Проба</th>
|
||||
<th className="px-3 py-2 font-medium">Маршрут</th>
|
||||
<th className="px-3 py-2 font-medium">Интерфейсы</th>
|
||||
<th className="px-3 py-2 font-medium">Протокол</th>
|
||||
<th className="px-3 py-2 font-medium text-right">TX</th>
|
||||
<th className="px-3 py-2 font-medium text-right">RX</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Ping RTT</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Loss</th>
|
||||
<th className="px-3 py-2 font-medium">Результат</th>
|
||||
<th className="px-3 py-2 font-medium">Ошибка</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{s.runs.map((x) => (
|
||||
<tr key={x.probeId} className="hover:bg-muted/30">
|
||||
<td className="px-3 py-2 font-mono">{x.probeId}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
{x.srcServerName} <span className="text-muted-foreground">→</span> {x.dstServerName}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-[10px]">
|
||||
<span className="block">{x.srcInterface || "—"}</span>
|
||||
<span className="block text-muted-foreground">{x.dstInterface || "—"}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">{x.protocol} / {x.direction} / {x.durationSec}s</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{x.txAvgMbps != null ? `${Number(x.txAvgMbps).toFixed(1)}` : "—"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{x.rxAvgMbps != null ? `${Number(x.rxAvgMbps).toFixed(1)}` : "—"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{x.pingRttMs != null ? `${x.pingRttMs} мс` : "—"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{x.pingLossPct != null ? `${x.pingLossPct}%` : "—"}</td>
|
||||
<td className="px-3 py-2">
|
||||
{x.ok ? (
|
||||
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400">ok</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive">ошибка</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 max-w-[200px]">
|
||||
<span className="text-destructive block truncate" title={x.error}>{x.error ?? ""}</span>
|
||||
{x.pingError ? (
|
||||
<span className="text-[10px] text-amber-600 dark:text-amber-400 block truncate" title={x.pingError ?? ""}>ping: {x.pingError}</span>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataPageCard>
|
||||
<SpeedSnapshotGrid runs={s.runs} />
|
||||
</DataPageCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -535,36 +340,6 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
}
|
||||
if (snap.job === "alert_engine") {
|
||||
const a = snap as AlertEngineRunSnapshot
|
||||
const transitionRu = (t: AlertEngineRuleDiagSnapshot["hitTransition"]) => {
|
||||
switch (t) {
|
||||
case "problem":
|
||||
return "проблема"
|
||||
case "recovery":
|
||||
return "восстановление"
|
||||
case "neutral":
|
||||
return "нейтрально"
|
||||
default:
|
||||
return "—"
|
||||
}
|
||||
}
|
||||
const blockedRu = (b: AlertEngineRuleDiagSnapshot["blocked"]) => {
|
||||
switch (b) {
|
||||
case "no_hit":
|
||||
return "условие не выполнено"
|
||||
case "stability":
|
||||
return "стабильность (confirmStabilitySec)"
|
||||
case "cooldown":
|
||||
return "cooldown"
|
||||
case "no_telegram":
|
||||
return "нет Telegram"
|
||||
case "dedupe_positive":
|
||||
return "дедуп восстановления"
|
||||
case "in_group":
|
||||
return "в группе (отдельно не шлём)"
|
||||
default:
|
||||
return "—"
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -605,40 +380,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
{a.ruleDiag && a.ruleDiag.length > 0 ? (
|
||||
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 space-y-2">
|
||||
<p className="text-[11px] font-medium text-muted-foreground">По правилам (почему не ушло в Telegram)</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px] border-collapse">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-foreground border-b border-border">
|
||||
<th className="py-1 pr-2 font-medium">ID правила</th>
|
||||
<th className="py-1 pr-2 font-medium">Сработало</th>
|
||||
<th className="py-1 pr-2 font-medium">Тип срабатывания</th>
|
||||
<th className="py-1 pr-2 font-medium">Стабильность</th>
|
||||
<th className="py-1 pr-2 font-medium">Кулдаун</th>
|
||||
<th className="py-1 pr-2 font-medium">Telegram</th>
|
||||
<th className="py-1 pr-2 font-medium">Сообщение</th>
|
||||
<th className="py-1 font-medium">Причина блока</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{a.ruleDiag.map((d) => (
|
||||
<tr key={d.ruleId} className="border-b border-border/60 font-mono">
|
||||
<td className="py-1 pr-2 max-w-[140px] truncate" title={d.ruleId}>
|
||||
{d.ruleId}
|
||||
</td>
|
||||
<td className="py-1 pr-2">{d.evalHit ? "Да" : "Нет"}</td>
|
||||
<td className="py-1 pr-2">{transitionRu(d.hitTransition)}</td>
|
||||
<td className="py-1 pr-2">{d.stabilityOk ? "Да" : "Нет"}</td>
|
||||
<td className="py-1 pr-2">{d.cooldownOk ? "Да" : "Нет"}</td>
|
||||
<td className="py-1 pr-2">{d.telegramOk ? "Да" : "Нет"}</td>
|
||||
<td className="py-1 pr-2 max-w-[280px] truncate text-muted-foreground" title={d.hitMessage ?? ""}>
|
||||
{d.hitMessage ?? "—"}
|
||||
</td>
|
||||
<td className="py-1 text-muted-foreground">{blockedRu(d.blocked)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataPageCard className="border-0 shadow-none bg-transparent">
|
||||
<AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} />
|
||||
</DataPageCard>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1057,6 +801,130 @@ export default function DataCollectionPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const schedulerGridRows = useMemo<SchedulerJobGridRow[]>(() => {
|
||||
return SCHEDULER_JOB_KEYS.map((jobKey) => {
|
||||
const j = schedulerJobsByKey[jobKey]
|
||||
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups"
|
||||
const enabled = fixedSchedule
|
||||
? Boolean(j?.enabled ?? true)
|
||||
: jobKey === "traffic"
|
||||
? draftTrafficEnabled
|
||||
: jobKey === "servers_rest_ping"
|
||||
? draftServersApiEnabled
|
||||
: jobKey === "uptime_resources"
|
||||
? draftResourcesEnabled
|
||||
: jobKey === "uptime_ping"
|
||||
? draftPingEnabled
|
||||
: jobKey === "uptime_speed"
|
||||
? draftSpeedEnabled
|
||||
: jobKey === "certificates_renew"
|
||||
? draftCertRenewEnabled
|
||||
: draftInternetPathEnabled
|
||||
const intervalValue = fixedSchedule
|
||||
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
||||
: jobKey === "traffic"
|
||||
? trafficIntervalDraft
|
||||
: jobKey === "servers_rest_ping"
|
||||
? serversApiIntervalDraft
|
||||
: jobKey === "uptime_resources"
|
||||
? uptimeResourceIntervalDraft
|
||||
: jobKey === "uptime_ping"
|
||||
? uptimeIntervalDraft
|
||||
: jobKey === "uptime_speed"
|
||||
? uptimeSpeedIntervalDraft
|
||||
: jobKey === "certificates_renew"
|
||||
? certRenewIntervalDraft
|
||||
: internetPathIntervalDraft
|
||||
const onIntervalChange = fixedSchedule
|
||||
? () => {}
|
||||
: jobKey === "traffic"
|
||||
? setTrafficIntervalDraft
|
||||
: jobKey === "servers_rest_ping"
|
||||
? setServersApiIntervalDraft
|
||||
: jobKey === "uptime_resources"
|
||||
? setUptimeResourceIntervalDraft
|
||||
: jobKey === "uptime_ping"
|
||||
? setUptimeIntervalDraft
|
||||
: jobKey === "uptime_speed"
|
||||
? setUptimeSpeedIntervalDraft
|
||||
: jobKey === "certificates_renew"
|
||||
? setCertRenewIntervalDraft
|
||||
: setInternetPathIntervalDraft
|
||||
const defaultInterval = fixedSchedule
|
||||
? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
||||
: jobKey === "traffic"
|
||||
? 30
|
||||
: jobKey === "servers_rest_ping"
|
||||
? 120
|
||||
: jobKey === "uptime_resources"
|
||||
? 300
|
||||
: jobKey === "uptime_ping"
|
||||
? 15
|
||||
: jobKey === "uptime_speed"
|
||||
? 60
|
||||
: jobKey === "certificates_renew"
|
||||
? 21600
|
||||
: 300
|
||||
|
||||
return {
|
||||
id: jobKey,
|
||||
jobKey,
|
||||
label: SCHEDULER_JOB_LABELS[jobKey] ?? jobKey,
|
||||
description: SCHEDULER_JOB_DESCRIPTIONS[jobKey],
|
||||
fixedSchedule,
|
||||
enabled,
|
||||
intervalValue,
|
||||
intervalReadOnly: fixedSchedule,
|
||||
intervalDisabled: !enabled && !fixedSchedule,
|
||||
defaultInterval,
|
||||
job: j,
|
||||
onEnabledChange: fixedSchedule
|
||||
? undefined
|
||||
: (nextEnabled) => {
|
||||
void handleJobEnabledChange(jobKey, nextEnabled)
|
||||
},
|
||||
onIntervalChange,
|
||||
onRunNow: async () => {
|
||||
setRunNowJobKey(jobKey)
|
||||
setCollectorError(null)
|
||||
try {
|
||||
await apiFetch(`/api/scheduler/jobs/${encodeURIComponent(jobKey)}/run-now`, {
|
||||
method: "POST",
|
||||
})
|
||||
await loadCollectors()
|
||||
} catch (e) {
|
||||
setCollectorError(e instanceof Error ? e.message : "Ошибка запуска")
|
||||
} finally {
|
||||
setRunNowJobKey(null)
|
||||
}
|
||||
},
|
||||
runNowLoading: runNowJobKey === jobKey,
|
||||
saveBusy: schedulerSaveBusy,
|
||||
}
|
||||
})
|
||||
}, [
|
||||
apiFetch,
|
||||
certRenewIntervalDraft,
|
||||
draftCertRenewEnabled,
|
||||
draftInternetPathEnabled,
|
||||
draftPingEnabled,
|
||||
draftResourcesEnabled,
|
||||
draftServersApiEnabled,
|
||||
draftSpeedEnabled,
|
||||
draftTrafficEnabled,
|
||||
handleJobEnabledChange,
|
||||
internetPathIntervalDraft,
|
||||
loadCollectors,
|
||||
runNowJobKey,
|
||||
schedulerJobsByKey,
|
||||
schedulerSaveBusy,
|
||||
serversApiIntervalDraft,
|
||||
trafficIntervalDraft,
|
||||
uptimeIntervalDraft,
|
||||
uptimeResourceIntervalDraft,
|
||||
uptimeSpeedIntervalDraft,
|
||||
])
|
||||
|
||||
const enabledJobsCount = useMemo(() => {
|
||||
const jobs = uptimeCollector?.scheduler?.jobs
|
||||
if (jobs?.length) return jobs.filter((job) => job.enabled).length
|
||||
@@ -1223,179 +1091,7 @@ export default function DataCollectionPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 pb-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||
<th className="text-left font-medium px-5 py-3">Задача</th>
|
||||
<th className="text-center font-medium px-3 py-3 w-[1%]">Вкл</th>
|
||||
<th className="text-left font-medium px-4 py-3">Интервал (с)</th>
|
||||
<th className="text-left font-medium px-4 py-3">Последний прогон</th>
|
||||
<th className="text-left font-medium px-4 py-3">Статус</th>
|
||||
<th className="text-right font-medium px-4 py-3 w-[1%]">Сейчас</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{SCHEDULER_JOB_KEYS.map((jobKey) => {
|
||||
const j = schedulerJobsByKey[jobKey]
|
||||
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups"
|
||||
const en = fixedSchedule
|
||||
? Boolean(j?.enabled ?? true)
|
||||
: jobKey === "traffic"
|
||||
? draftTrafficEnabled
|
||||
: jobKey === "servers_rest_ping"
|
||||
? draftServersApiEnabled
|
||||
: jobKey === "uptime_resources"
|
||||
? draftResourcesEnabled
|
||||
: jobKey === "uptime_ping"
|
||||
? draftPingEnabled
|
||||
: jobKey === "uptime_speed"
|
||||
? draftSpeedEnabled
|
||||
: jobKey === "certificates_renew"
|
||||
? draftCertRenewEnabled
|
||||
: draftInternetPathEnabled
|
||||
const iv = fixedSchedule
|
||||
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
||||
: jobKey === "traffic"
|
||||
? trafficIntervalDraft
|
||||
: jobKey === "servers_rest_ping"
|
||||
? serversApiIntervalDraft
|
||||
: jobKey === "uptime_resources"
|
||||
? uptimeResourceIntervalDraft
|
||||
: jobKey === "uptime_ping"
|
||||
? uptimeIntervalDraft
|
||||
: jobKey === "uptime_speed"
|
||||
? uptimeSpeedIntervalDraft
|
||||
: jobKey === "certificates_renew"
|
||||
? certRenewIntervalDraft
|
||||
: internetPathIntervalDraft
|
||||
const setIv = fixedSchedule
|
||||
? () => {}
|
||||
: jobKey === "traffic"
|
||||
? setTrafficIntervalDraft
|
||||
: jobKey === "servers_rest_ping"
|
||||
? setServersApiIntervalDraft
|
||||
: jobKey === "uptime_resources"
|
||||
? setUptimeResourceIntervalDraft
|
||||
: jobKey === "uptime_ping"
|
||||
? setUptimeIntervalDraft
|
||||
: jobKey === "uptime_speed"
|
||||
? setUptimeSpeedIntervalDraft
|
||||
: jobKey === "certificates_renew"
|
||||
? setCertRenewIntervalDraft
|
||||
: setInternetPathIntervalDraft
|
||||
const defSec = fixedSchedule
|
||||
? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
||||
: jobKey === "traffic"
|
||||
? 30
|
||||
: jobKey === "servers_rest_ping"
|
||||
? 120
|
||||
: jobKey === "uptime_resources"
|
||||
? 300
|
||||
: jobKey === "uptime_ping"
|
||||
? 15
|
||||
: jobKey === "uptime_speed"
|
||||
? 60
|
||||
: jobKey === "certificates_renew"
|
||||
? 21600
|
||||
: 300
|
||||
return (
|
||||
<tr key={jobKey} className="hover:bg-muted/40">
|
||||
<td className="px-5 py-3 align-top">
|
||||
<span className="font-medium">{SCHEDULER_JOB_LABELS[jobKey] ?? jobKey}</span>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 leading-snug">
|
||||
{SCHEDULER_JOB_DESCRIPTIONS[jobKey]}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground font-mono mt-1">{jobKey}</p>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-center align-top">
|
||||
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
|
||||
<FormToggle
|
||||
checked={en}
|
||||
disabled={fixedSchedule || schedulerSaveBusy}
|
||||
onChange={(v) => {
|
||||
if (fixedSchedule || schedulerSaveBusy) return
|
||||
void handleJobEnabledChange(jobKey, v)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 w-28 align-top">
|
||||
<Input
|
||||
value={iv}
|
||||
onChange={(e) => setIv(e.target.value)}
|
||||
className="h-8 text-sm tabular-nums"
|
||||
inputMode="numeric"
|
||||
readOnly={fixedSchedule}
|
||||
disabled={!en && !fixedSchedule}
|
||||
placeholder={String(defSec)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground align-top">
|
||||
{j?.lastFinishedAt ? new Date(j.lastFinishedAt).toLocaleString("ru-RU") : "—"}
|
||||
{j?.lastDurationMs != null && (
|
||||
<span className="block text-[11px]">{j.lastDurationMs} мс</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{j?.running ? (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
выполняется
|
||||
</Badge>
|
||||
) : null}
|
||||
{j?.lastStatus ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
j.lastStatus === "ok" && "border-emerald-500/40 text-emerald-700 dark:text-emerald-400",
|
||||
j.lastStatus === "error" && "border-destructive/50 text-destructive",
|
||||
)}
|
||||
>
|
||||
{j.lastStatus}
|
||||
</Badge>
|
||||
) : null}
|
||||
{j?.lastError ? (
|
||||
<span
|
||||
className="text-[10px] text-destructive max-w-[200px] truncate block"
|
||||
title={j.lastError}
|
||||
>
|
||||
{j.lastError}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right align-top">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={j?.running || runNowJobKey !== null}
|
||||
onClick={async () => {
|
||||
setRunNowJobKey(jobKey)
|
||||
setCollectorError(null)
|
||||
try {
|
||||
await apiFetch(`/api/scheduler/jobs/${encodeURIComponent(jobKey)}/run-now`, {
|
||||
method: "POST",
|
||||
})
|
||||
await loadCollectors()
|
||||
} catch (e) {
|
||||
setCollectorError(e instanceof Error ? e.message : "Ошибка запуска")
|
||||
} finally {
|
||||
setRunNowJobKey(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-3.5", runNowJobKey === jobKey && "animate-spin")} />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
|
||||
<Separator />
|
||||
<div className="space-y-3 px-5 py-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
|
||||
+27
-53
@@ -2,11 +2,13 @@
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { DomainsDataGrid } from "@/components/data-grids/domains-data-grid"
|
||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { domains as mockDomains } from "@/lib/data"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -16,6 +18,7 @@ export default function DomainsPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
|
||||
@@ -25,6 +28,17 @@ export default function DomainsPage() {
|
||||
return snapshot?.domains ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return rows
|
||||
const q = search.toLowerCase()
|
||||
return rows.filter(
|
||||
(r) =>
|
||||
r.domain.toLowerCase().includes(q) ||
|
||||
r.asn.toLowerCase().includes(q) ||
|
||||
r.filter.toLowerCase().includes(q),
|
||||
)
|
||||
}, [rows, search])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
@@ -60,59 +74,19 @@ export default function DomainsPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
data={rows}
|
||||
isLoading={useEvoCatalog && loading && !snapshot}
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по домену…"
|
||||
searchKeys={["domain", "asn", "filter"]}
|
||||
columns={[
|
||||
{
|
||||
key: "domain",
|
||||
label: "Домен",
|
||||
render: (d) => <span className="font-medium">{d.domain}</span>,
|
||||
},
|
||||
{
|
||||
key: "resolvedIp",
|
||||
label: "Resolved IP",
|
||||
render: (d) => <span className="font-mono text-xs text-muted-foreground">{d.resolvedIp}</span>,
|
||||
},
|
||||
{
|
||||
key: "asn",
|
||||
label: "ASN",
|
||||
render: (d) => <span className="font-mono text-xs">{d.asn}</span>,
|
||||
},
|
||||
{
|
||||
key: "purpose",
|
||||
label: "Назначение",
|
||||
render: (d) => (
|
||||
<span className="text-xs border border-border rounded px-2 py-0.5">{d.purpose}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "filter",
|
||||
label: "Фильтр",
|
||||
render: (d) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updated",
|
||||
label: "Обновлён",
|
||||
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
|
||||
},
|
||||
{
|
||||
key: "enabled",
|
||||
label: "Статус",
|
||||
render: (d) => (
|
||||
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
|
||||
{d.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
countLabel={`${filtered.length} доменов`}
|
||||
/>
|
||||
<DomainsDataGrid
|
||||
domains={filtered}
|
||||
isLoading={useEvoCatalog && loading && !snapshot}
|
||||
pagination={useEvoCatalog}
|
||||
/>
|
||||
</DataPageCard>
|
||||
</div>
|
||||
</div>
|
||||
<FileImportDialog
|
||||
|
||||
+22
-298
@@ -5,6 +5,11 @@ import { PageHeader } from "@/components/page-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { Flag } from "@/components/flag"
|
||||
import {
|
||||
FiltersDataGrid,
|
||||
type RecursiveRouteLite,
|
||||
} from "@/components/data-grids/filters-data-grid"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -36,42 +41,6 @@ import { toast } from "sonner"
|
||||
|
||||
type FilterRouterSyncStatus = "synced" | "drift" | "missing"
|
||||
|
||||
function RouterSyncMarker({
|
||||
status,
|
||||
}: {
|
||||
status: FilterRouterSyncStatus | null | "skip"
|
||||
}) {
|
||||
if (status === "skip") {
|
||||
return <span className="size-3.5 shrink-0 block" aria-hidden />
|
||||
}
|
||||
const icon =
|
||||
status === "synced"
|
||||
? <CheckCircle2Icon className="size-3.5 text-emerald-600 dark:text-emerald-500 shrink-0" />
|
||||
: status === "drift"
|
||||
? <AlertTriangleIcon className="size-3.5 text-amber-500 shrink-0" />
|
||||
: status === "missing"
|
||||
? <XCircleIcon className="size-3.5 text-destructive shrink-0" />
|
||||
: <CircleDashedIcon className="size-3.5 text-muted-foreground/35 shrink-0" />
|
||||
const title =
|
||||
status === "synced"
|
||||
? "Совпадает с цепочкой bgp-in на MikroTik"
|
||||
: status === "drift"
|
||||
? "В БД и на роутере разное действие (gateway, blackhole или out-interface)"
|
||||
: status === "missing"
|
||||
? "Эта community не найдена в правиле bgp-in на роутере"
|
||||
: "Не проверено — нажмите «Сверить с роутером»"
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="inline-flex cursor-default border-0 bg-transparent p-0">
|
||||
{icon}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{title}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function newId() { return `r${Date.now()}-${Math.random().toString(36).slice(2, 6)}` }
|
||||
function innerIpToGateway(ip: string) { return ip.split("/")[0] }
|
||||
|
||||
@@ -138,16 +107,6 @@ function dedupeRecursiveRoutesByDstAddress(routes: RecursiveRouteLite[]): Recurs
|
||||
})
|
||||
}
|
||||
|
||||
interface RecursiveRouteLite {
|
||||
id: string
|
||||
dstAddress: string
|
||||
gateway: string
|
||||
distance: number
|
||||
routingTable: string
|
||||
comment: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
const COMMUNITY_NAMES: Record<string, string> = {
|
||||
"65001:100": "youtube-bypass",
|
||||
"65001:200": "streaming-eu",
|
||||
@@ -479,171 +438,6 @@ function CommunityInput({
|
||||
)
|
||||
}
|
||||
|
||||
// ── filter rule row ────────────────────────────────────────────────────────────
|
||||
|
||||
function FilterRow({
|
||||
rule, index, isLast, onEdit, onDelete, onMoveUp, onMoveDown, tunnelsList, serversList,
|
||||
communityNameMap,
|
||||
recursiveRoutes,
|
||||
routerSyncStatus,
|
||||
}: {
|
||||
rule: FilterRule; index: number; isLast: boolean
|
||||
onEdit: () => void; onDelete: () => void; onMoveUp: () => void; onMoveDown: () => void
|
||||
tunnelsList: GreTunnel[]
|
||||
serversList: Server[]
|
||||
communityNameMap: Record<string, string>
|
||||
recursiveRoutes: RecursiveRouteLite[]
|
||||
routerSyncStatus?: FilterRouterSyncStatus | null | "skip"
|
||||
}) {
|
||||
const [confirmDel, setConfirmDel] = useState(false)
|
||||
const isBlackhole = rule.action === "blackhole"
|
||||
const isRecRef = !isBlackhole && isRecursiveGatewayRef(rule.gatewayTunnelId)
|
||||
const recRowByRef = isRecRef ? recursiveRoutes.find(r => r.id === rule.gatewayTunnelId.slice(4)) : undefined
|
||||
const recRowByHop =
|
||||
!isBlackhole && !(rule.gatewayTunnelId ?? "").trim() && rule.gateway.trim()
|
||||
? pickRecursiveRouteByGatewayHop(recursiveRoutes, rule.gateway)
|
||||
: undefined
|
||||
const recRow = recRowByRef ?? recRowByHop
|
||||
const treatAsRecursive =
|
||||
!isBlackhole && (isRecRef || !!recRowByHop)
|
||||
const tunnel = !isBlackhole && !treatAsRecursive
|
||||
? tunnelsList.find(t => t.id === rule.gatewayTunnelId)
|
||||
: undefined
|
||||
const remoteSrv = tunnel ? serversList.find(s => s.host === tunnel.remoteAddress) : undefined
|
||||
const communityName = communityNameMap[rule.community] ?? rule.communityName
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"group grid items-center gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors",
|
||||
"grid-cols-[20px_20px_22px_1fr_1fr_1fr_64px]",
|
||||
isBlackhole && "bg-red-500/[0.03]",
|
||||
)}>
|
||||
{/* priority */}
|
||||
<div className="flex items-center justify-center text-[11px] font-mono text-muted-foreground/40 select-none">
|
||||
{isLast
|
||||
? <StarIcon className="size-3 text-amber-400 fill-amber-400" aria-label="Наивысший приоритет" />
|
||||
: <span>{index + 1}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* reorder */}
|
||||
<div className="flex flex-col gap-px opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={onMoveUp} disabled={index === 0}
|
||||
className="text-muted-foreground/50 hover:text-foreground disabled:opacity-20 transition-colors">
|
||||
<ChevronUpIcon className="size-3" />
|
||||
</button>
|
||||
<button onClick={onMoveDown} disabled={isLast}
|
||||
className="text-muted-foreground/50 hover:text-foreground disabled:opacity-20 transition-colors">
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* MikroTik sync marker */}
|
||||
<div className="flex items-center justify-center">
|
||||
<RouterSyncMarker
|
||||
status={routerSyncStatus === undefined ? "skip" : routerSyncStatus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* community */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border font-mono",
|
||||
isBlackhole
|
||||
? "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/25"
|
||||
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
)}>
|
||||
{rule.community}
|
||||
</span>
|
||||
{isBlackhole && (
|
||||
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold border
|
||||
bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20 uppercase tracking-wide">
|
||||
⊘ blackhole
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{communityName && (
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">{communityName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* gateway / tunnel — or blackhole target */}
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
{isBlackhole ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-1.5 rounded-full shrink-0 bg-red-500 animate-pulse" />
|
||||
<span className="font-mono text-xs font-medium text-red-600 dark:text-red-400">type=blackhole</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className={cn(
|
||||
"size-1.5 rounded-full shrink-0",
|
||||
treatAsRecursive
|
||||
? "bg-sky-500"
|
||||
: tunnel?.status === "up"
|
||||
? "bg-[var(--status-online)]"
|
||||
: tunnel?.status === "degraded"
|
||||
? "bg-[var(--status-degraded)]"
|
||||
: "bg-[var(--status-offline)]",
|
||||
)} />
|
||||
{treatAsRecursive ? (
|
||||
<>
|
||||
<RouteIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-xs font-medium">
|
||||
{recRow ? gatewayFromRecursiveDst(recRow.dstAddress) : rule.gateway}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-muted-foreground border border-border rounded px-1 uppercase tracking-wide">
|
||||
recursive
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{remoteSrv && <Flag code={remoteSrv.country} />}
|
||||
<span className="font-mono text-xs font-medium">{rule.gateway}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{treatAsRecursive ? (
|
||||
recRow ? (
|
||||
<p className="text-[11px] text-muted-foreground truncate pl-3">{recRow.dstAddress}</p>
|
||||
) : isRecRef ? (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400 truncate pl-3">
|
||||
рекурсивный маршрут (нет строки в списке — синхронизируйте «Рекурсивные маршруты»)
|
||||
</p>
|
||||
) : null
|
||||
) : tunnel ? (
|
||||
<p className="text-[11px] text-muted-foreground truncate pl-3">{tunnel.name}</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* description */}
|
||||
<p className="text-xs text-muted-foreground truncate">{rule.description || "—"}</p>
|
||||
|
||||
{/* actions */}
|
||||
<div className="flex items-center gap-0.5 justify-end opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button size="sm" variant="ghost" className="size-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onEdit}>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost"
|
||||
className={cn("size-7 p-0 transition-colors",
|
||||
confirmDel
|
||||
? "text-destructive bg-destructive/10 hover:bg-destructive/20"
|
||||
: "text-muted-foreground hover:text-destructive",
|
||||
)}
|
||||
onClick={() => { if (!confirmDel) setConfirmDel(true); else onDelete() }}
|
||||
onBlur={() => setConfirmDel(false)}>
|
||||
{confirmDel ? <AlertCircleIcon className="size-3.5" /> : <TrashIcon className="size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── rule sheet ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RuleForm {
|
||||
@@ -1497,8 +1291,6 @@ function CopyRulesSheet({
|
||||
|
||||
// ── page ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
type SortKey = "community" | "gateway" | "description"
|
||||
|
||||
interface BackendServer {
|
||||
id: number
|
||||
name: string
|
||||
@@ -1530,11 +1322,6 @@ function makeApiFetch(backendUrl: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function SortIndicator({ k, sortKey, sortAsc }: { k: SortKey; sortKey: SortKey; sortAsc: boolean }) {
|
||||
if (sortKey !== k) return <ArrowUpDownIcon className="size-3 opacity-30" />
|
||||
return sortAsc ? <ArrowUpIcon className="size-3" /> : <ArrowDownIcon className="size-3" />
|
||||
}
|
||||
|
||||
export default function FiltersPage() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const evo = useEvoBGP()
|
||||
@@ -1602,8 +1389,6 @@ export default function FiltersPage() {
|
||||
}, [isLive, apiFetch])
|
||||
const [selectedServerId, setSelectedServerId] = useState("srv1")
|
||||
const [search, setSearch] = useState("")
|
||||
const [sortKey, setSortKey] = useState<SortKey>("community")
|
||||
const [sortAsc, setSortAsc] = useState(true)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
|
||||
const [sheetInitial, setSheetInitial]= useState<RuleForm>(emptyForm())
|
||||
@@ -1708,24 +1493,14 @@ export default function FiltersPage() {
|
||||
|
||||
const filteredRules = useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
const list = q
|
||||
? currentRules.filter(r =>
|
||||
if (!q) return currentRules
|
||||
return currentRules.filter((r) =>
|
||||
r.community.includes(q) ||
|
||||
(communityNameMap[r.community] ?? "").toLowerCase().includes(q) ||
|
||||
r.gateway.includes(q) ||
|
||||
r.description.toLowerCase().includes(q)
|
||||
r.description.toLowerCase().includes(q),
|
||||
)
|
||||
: [...currentRules]
|
||||
if (search) {
|
||||
const mult = sortAsc ? 1 : -1
|
||||
list.sort((a, b) => {
|
||||
if (sortKey === "community") return mult * a.community.localeCompare(b.community)
|
||||
if (sortKey === "gateway") return mult * a.gateway.localeCompare(b.gateway)
|
||||
return mult * a.description.localeCompare(b.description)
|
||||
})
|
||||
}
|
||||
return list
|
||||
}, [currentRules, search, sortKey, sortAsc, communityNameMap])
|
||||
}, [currentRules, search, communityNameMap])
|
||||
|
||||
const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => {
|
||||
setRouterCompare(rc => (rc && rc.serverId === serverId ? null : rc))
|
||||
@@ -1843,10 +1618,6 @@ export default function FiltersPage() {
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSort = (k: SortKey) => {
|
||||
if (sortKey === k) setSortAsc(v => !v); else { setSortKey(k); setSortAsc(true) }
|
||||
}
|
||||
|
||||
if (!selectedServer) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
@@ -2031,7 +1802,7 @@ export default function FiltersPage() {
|
||||
)
|
||||
})()}
|
||||
|
||||
<Card className="overflow-hidden py-0 gap-0">
|
||||
<DataPageCard>
|
||||
|
||||
{/* selected server header */}
|
||||
<div className="flex items-center gap-2.5 px-4 py-3 border-b bg-muted/10">
|
||||
@@ -2087,71 +1858,24 @@ export default function FiltersPage() {
|
||||
<p className="text-sm">Ничего не найдено</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* table header with sort */}
|
||||
<div className={cn(
|
||||
"grid items-center gap-3 px-4 py-1.5 border-b bg-muted/30",
|
||||
"grid-cols-[20px_20px_22px_1fr_1fr_1fr_64px]",
|
||||
"text-[10px] font-semibold uppercase tracking-widest text-muted-foreground",
|
||||
)}>
|
||||
<span>#</span>
|
||||
<span />
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="cursor-help text-center font-mono normal-case tracking-normal border-0 bg-transparent p-0 w-full">
|
||||
MT
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
Совпадение с MikroTik (bgp-in): нажмите «Сверить с роутером»
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<button onClick={() => toggleSort("community")}
|
||||
className="flex items-center gap-1 hover:text-foreground text-left transition-colors">
|
||||
Community <SortIndicator k="community" sortKey={sortKey} sortAsc={sortAsc} />
|
||||
</button>
|
||||
<button onClick={() => toggleSort("gateway")}
|
||||
className="flex items-center gap-1 hover:text-foreground text-left transition-colors">
|
||||
Gateway <SortIndicator k="gateway" sortKey={sortKey} sortAsc={sortAsc} />
|
||||
</button>
|
||||
<button onClick={() => toggleSort("description")}
|
||||
className="flex items-center gap-1 hover:text-foreground text-left transition-colors">
|
||||
Описание <SortIndicator k="description" sortKey={sortKey} sortAsc={sortAsc} />
|
||||
</button>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{/* rows */}
|
||||
<div className="divide-y divide-border/60">
|
||||
{(search ? filteredRules : currentRules).map((rule, i, arr) => (
|
||||
<FilterRow
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
index={i}
|
||||
isLast={i === arr.length - 1}
|
||||
<FiltersDataGrid
|
||||
rules={search ? filteredRules : currentRules}
|
||||
tunnelsList={allTunnels}
|
||||
serversList={allServers}
|
||||
communityNameMap={communityNameMap}
|
||||
recursiveRoutes={recRoutesByServer[selectedServerId] ?? []}
|
||||
routerSyncStatus={
|
||||
!isLive
|
||||
? undefined
|
||||
: !routerCompare || routerCompare.serverId !== selectedServerId
|
||||
routerSyncByCommunity={
|
||||
!isLive || !routerCompare || routerCompare.serverId !== selectedServerId
|
||||
? null
|
||||
: routerCompare.byCommunity[rule.community.trim()] ?? null
|
||||
: routerCompare.byCommunity
|
||||
}
|
||||
onEdit={() => openEdit(rule)}
|
||||
onDelete={() => handleDelete(rule.id)}
|
||||
onMoveUp={() => handleMoveUp(currentRules.findIndex(r => r.id === rule.id))}
|
||||
onMoveDown={() => handleMoveDown(currentRules.findIndex(r => r.id === rule.id))}
|
||||
isLive={isLive}
|
||||
enableSorting={!!search}
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
onMoveUp={(id) => handleMoveUp(currentRules.findIndex((r) => r.id === id))}
|
||||
onMoveDown={(id) => handleMoveDown(currentRules.findIndex((r) => r.id === id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* footer hint */}
|
||||
<div className="px-4 py-2 text-[11px] text-muted-foreground/40 flex items-center gap-1.5 border-t">
|
||||
<StarIcon className="size-3 text-amber-400 fill-amber-400 shrink-0" />
|
||||
Последнее правило имеет наивысший приоритет в RouterOS
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* add rule shortcut */}
|
||||
@@ -2160,7 +1884,7 @@ export default function FiltersPage() {
|
||||
<PlusIcon className="size-3.5" />
|
||||
Добавить правило для {selectedServer.name}
|
||||
</button>
|
||||
</Card>
|
||||
</DataPageCard>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+18
-167
@@ -2,6 +2,13 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import {
|
||||
FirewallRulesDataGrid,
|
||||
ActionBadge,
|
||||
ChainBadge,
|
||||
} from "@/components/data-grids/firewall-rules-data-grid"
|
||||
import { FirewallScenarioRulesDataGrid } from "@/components/data-grids/firewall-scenario-rules-data-grid"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
||||
import { firewallRules, type FirewallRule } from "@/lib/data"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
@@ -342,23 +349,7 @@ function fmtHits(n: number): string {
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function ActionBadge({ action }: { action: string }) {
|
||||
const cls = ACTION_STYLES[action] ?? "bg-muted text-muted-foreground border-border"
|
||||
return (
|
||||
<span className={cn("text-[11px] font-mono font-medium px-2 py-0.5 rounded border whitespace-nowrap", cls)}>
|
||||
{action}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ChainBadge({ chain }: { chain: string }) {
|
||||
const cls = CHAIN_STYLES[chain] ?? "bg-muted text-muted-foreground"
|
||||
return (
|
||||
<span className={cn("text-[11px] font-mono px-2 py-0.5 rounded", cls)}>
|
||||
{chain}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
// ActionBadge, ChainBadge — из firewall-rules-data-grid
|
||||
|
||||
function NativeSelect({ value, onChange, children, className }: {
|
||||
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
|
||||
@@ -1143,55 +1134,13 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
||||
{/* Rules table */}
|
||||
{rules.length > 0 ? (
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/30 text-muted-foreground">
|
||||
<th className="text-left px-3 py-2 w-6">#</th>
|
||||
<th className="text-left px-3 py-2">Цепочка</th>
|
||||
<th className="text-left px-3 py-2">Действие</th>
|
||||
<th className="text-left px-3 py-2">Src</th>
|
||||
<th className="text-left px-3 py-2">Dst</th>
|
||||
<th className="text-left px-3 py-2">Порт</th>
|
||||
<th className="text-left px-3 py-2">Iface</th>
|
||||
<th className="text-left px-3 py-2">Комментарий</th>
|
||||
<th className="w-24 px-2 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rules.map((r, i) => (
|
||||
<tr key={r.id} className={cn(
|
||||
"hover:bg-muted/20 transition-colors",
|
||||
!r.enabled && "opacity-40",
|
||||
)}>
|
||||
<td className="px-3 py-1.5 text-muted-foreground tabular-nums">{i + 1}</td>
|
||||
<td className="px-3 py-1.5"><ChainBadge chain={r.chain} /></td>
|
||||
<td className="px-3 py-1.5"><ActionBadge action={r.action} /></td>
|
||||
<td className="px-3 py-1.5 font-mono text-muted-foreground max-w-[90px] truncate">{r.src || "any"}</td>
|
||||
<td className="px-3 py-1.5 font-mono text-muted-foreground max-w-[90px] truncate">{r.dst || "any"}</td>
|
||||
<td className="px-3 py-1.5 font-mono text-muted-foreground">{r.port || "—"}</td>
|
||||
<td className="px-3 py-1.5 font-mono text-muted-foreground">{r.iface || "—"}</td>
|
||||
<td className="px-3 py-1.5 text-muted-foreground/70 max-w-[110px] truncate">{r.comment || "—"}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<div className="flex items-center gap-0.5 justify-end">
|
||||
<button type="button" onClick={() => toggleEnabled(r.id)}
|
||||
title={r.enabled ? "Отключить" : "Включить"}
|
||||
className="p-0.5 text-muted-foreground/40 hover:text-foreground transition-colors">
|
||||
<PowerIcon className="size-3.5" />
|
||||
</button>
|
||||
<button type="button" onClick={() => moveRule(r.id, -1)} disabled={i === 0}
|
||||
className="p-0.5 text-muted-foreground/40 hover:text-foreground disabled:opacity-20 transition-colors">▲</button>
|
||||
<button type="button" onClick={() => moveRule(r.id, 1)} disabled={i === rules.length - 1}
|
||||
className="p-0.5 text-muted-foreground/40 hover:text-foreground disabled:opacity-20 transition-colors">▼</button>
|
||||
<button type="button" onClick={() => removeRule(r.id)}
|
||||
className="p-0.5 ml-0.5 text-muted-foreground/40 hover:text-red-500 transition-colors">
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<FirewallScenarioRulesDataGrid
|
||||
rules={rules}
|
||||
onToggleEnabled={toggleEnabled}
|
||||
onMoveUp={(id) => moveRule(id, -1)}
|
||||
onMoveDown={(id) => moveRule(id, 1)}
|
||||
onRemove={removeRule}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
!addOpen && (
|
||||
@@ -1683,104 +1632,6 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Rules Table ──────────────────────────────────────────────────────────────
|
||||
|
||||
function RulesTable({ rules, onToggle, onEdit }: {
|
||||
rules: FirewallRule[]
|
||||
onToggle: (id: string) => void
|
||||
onEdit: (r: FirewallRule) => void
|
||||
}) {
|
||||
if (rules.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||||
<ShieldOffIcon className="size-10 mb-3 opacity-30" />
|
||||
<p className="text-sm font-medium">Правила не найдены</p>
|
||||
<p className="text-xs mt-1">Попробуйте изменить фильтр или добавьте новое правило</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||
<th className="text-left font-medium px-5 py-3 w-8">#</th>
|
||||
<th className="text-left font-medium px-4 py-3">Цепочка</th>
|
||||
<th className="text-left font-medium px-4 py-3">Действие</th>
|
||||
<th className="text-left font-medium px-4 py-3">Источник</th>
|
||||
<th className="text-left font-medium px-4 py-3">Назначение</th>
|
||||
<th className="text-left font-medium px-4 py-3">Протокол</th>
|
||||
<th className="text-left font-medium px-4 py-3">Порт</th>
|
||||
<th className="text-left font-medium px-4 py-3">Интерфейс</th>
|
||||
<th className="text-right font-medium px-4 py-3">Пакетов</th>
|
||||
<th className="text-left font-medium px-4 py-3 w-12">Вкл</th>
|
||||
<th className="w-10 px-3 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rules.map((r, i) => (
|
||||
<tr key={r.id}
|
||||
className={cn("hover:bg-muted/40 transition-colors", !r.enabled && "opacity-40")}>
|
||||
<td className="px-5 py-2.5 font-mono text-xs text-muted-foreground">{i + 1}</td>
|
||||
<td className="px-4 py-2.5"><ChainBadge chain={r.chain} /></td>
|
||||
<td className="px-4 py-2.5"><ActionBadge action={r.action} /></td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground max-w-[140px] truncate">
|
||||
{r.src || "any"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground max-w-[140px] truncate">
|
||||
{r.dst || "any"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs font-mono">{r.proto}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{r.port || "—"}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{r.iface || "—"}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<span className={cn(
|
||||
"text-xs font-mono tabular-nums",
|
||||
r.hits > 1_000_000 ? "text-emerald-600 dark:text-emerald-400 font-semibold"
|
||||
: r.hits > 10_000 ? "text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}>
|
||||
{fmtHits(r.hits)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<FormToggle checked={r.enabled} onChange={() => onToggle(r.id)} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={
|
||||
<Button variant="ghost" size="icon" className="size-7">
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
} />
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(r)}>
|
||||
<PencilIcon className="size-4" />Редактировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CopyIcon className="size-4" />Дублировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onToggle(r.id)}>
|
||||
<PowerIcon className="size-4" />
|
||||
{r.enabled ? "Отключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<Trash2Icon className="size-4" />Удалить правило
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
export default function FirewallPage() {
|
||||
const [rules, setRules] = useState<FirewallRule[]>(firewallRules)
|
||||
@@ -1932,7 +1783,7 @@ export default function FirewallPage() {
|
||||
) : chainGroup === "simulator" ? (
|
||||
<SimulatorTab rules={rules} />
|
||||
) : (
|
||||
<Card>
|
||||
<DataPageCard>
|
||||
{/* toolbar */}
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
||||
{/* IP family selector */}
|
||||
@@ -1980,8 +1831,8 @@ export default function FirewallPage() {
|
||||
<span className="text-sm text-muted-foreground ml-auto">{filteredRules.length} правил</span>
|
||||
</div>
|
||||
|
||||
<RulesTable rules={filteredRules} onToggle={toggleRule} onEdit={openEdit} />
|
||||
</Card>
|
||||
<FirewallRulesDataGrid rules={filteredRules} onToggle={toggleRule} onEdit={openEdit} />
|
||||
</DataPageCard>
|
||||
)}
|
||||
|
||||
{/* RouterOS reference */}
|
||||
|
||||
+14
-163
@@ -2,6 +2,9 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { GreTunnelsDataGrid } from "@/components/data-grids/gre-tunnels-data-grid"
|
||||
import { GrePoolsDataGrid } from "@/components/data-grids/gre-pools-data-grid"
|
||||
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
||||
import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data"
|
||||
import type { GrePool, GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion, Server } from "@/lib/data"
|
||||
@@ -450,7 +453,7 @@ export default function GrePage() {
|
||||
|
||||
{/* ── Tunnels ── */}
|
||||
{pageTab === "tunnels" && (
|
||||
<Card>
|
||||
<DataPageCard>
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||||
{tunnelTabs.map((t) => (
|
||||
@@ -469,177 +472,25 @@ export default function GrePage() {
|
||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} туннелей</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||
<th className="text-left font-medium px-5 py-3">Интерфейс / Сервер</th>
|
||||
<th className="text-left font-medium px-4 py-3">Эндпоинты</th>
|
||||
<th className="text-left font-medium px-4 py-3">Внутренний IP</th>
|
||||
<th className="text-left font-medium px-4 py-3">Пул</th>
|
||||
<th className="text-left font-medium px-4 py-3">IPsec</th>
|
||||
<th className="text-left font-medium px-4 py-3">Шифрование</th>
|
||||
<th className="text-center font-medium px-4 py-3">MTU</th>
|
||||
<th className="text-left font-medium px-4 py-3">Keepalive</th>
|
||||
<th className="text-left font-medium px-4 py-3">Статус</th>
|
||||
<th className="w-20 px-3 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filtered.map((t, index) => {
|
||||
const srv = serverById[t.serverId]
|
||||
const pool = poolById[t.poolId]
|
||||
return (
|
||||
<tr key={`${t.id}:${t.serverId}:${t.name}:${index}`} className="hover:bg-muted/40 transition-colors">
|
||||
<td className="px-5 py-3">
|
||||
<p className="font-medium font-mono text-[13px]">{t.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
|
||||
{srv && <Flag code={srv.country} />}
|
||||
{srv?.name ?? t.serverId}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-mono text-xs">
|
||||
{t.localAddress === "0.0.0.0" ? <span className="text-muted-foreground">авто</span> : t.localAddress}
|
||||
</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">→ {t.remoteAddress}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-mono text-xs">{t.localInnerIp}</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">{t.remoteInnerIp}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs text-muted-foreground font-mono">{pool?.name ?? "—"}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3"><IpsecBadge secured={!!t.ipsec} /></td>
|
||||
<td className="px-4 py-3">
|
||||
{t.ipsec ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-mono">{ENC_LABELS[t.ipsec.encAlg]} / {AUTH_LABELS[t.ipsec.authAlg]}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{DH_LABELS[t.ipsec.dhGroup].split(" ")[0]} · {IKE_LABELS[t.ipsec.ikeVersion]}{t.ipsec.pfs && " · PFS"}
|
||||
</span>
|
||||
</div>
|
||||
) : <span className="text-xs text-muted-foreground">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center font-mono text-xs">{t.mtu}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
|
||||
{t.keepaliveInterval === 0 ? "откл." : `${t.keepaliveInterval}с / ${t.keepaliveRetries}`}
|
||||
</td>
|
||||
<td className="px-4 py-3"><TunnelStatus status={t.status} /></td>
|
||||
|
||||
{/* actions */}
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
{/* Code preview button */}
|
||||
<Button
|
||||
variant="ghost" size="icon" className="size-7"
|
||||
title="Предпросмотр кода RouterOS"
|
||||
onClick={() => setCodePreviewTunnel(t)}
|
||||
>
|
||||
<CodeXmlIcon className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
{/* Actions dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={
|
||||
<Button variant="ghost" size="icon" className="size-7">
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
} />
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>{t.name}</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setCodePreviewTunnel(t)}>
|
||||
<CodeXmlIcon className="size-4" /> Просмотр кода
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<PencilIcon className="size-4" /> Редактировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<PowerIcon className="size-4" />
|
||||
{t.enabled ? "Выключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<Trash2Icon className="size-4" /> Удалить туннель
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<GreTunnelsDataGrid
|
||||
tunnels={filtered}
|
||||
servers={displayServers}
|
||||
pools={displayPools}
|
||||
onCodePreview={setCodePreviewTunnel}
|
||||
/>
|
||||
</DataPageCard>
|
||||
)}
|
||||
|
||||
{/* ── IP Pools ── */}
|
||||
{pageTab === "pools" && (
|
||||
<Card>
|
||||
<DataPageCard>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b">
|
||||
<span className="text-sm text-muted-foreground">{displayPools.length} пула</span>
|
||||
<Button size="sm" variant="outline" onClick={() => { setPForm(defaultPoolForm); setPoolOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Добавить пул
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||
<th className="text-left font-medium px-5 py-3">Имя пула</th>
|
||||
<th className="text-left font-medium px-4 py-3">Диапазон CIDR</th>
|
||||
<th className="text-right font-medium px-4 py-3">Назначено /30</th>
|
||||
<th className="text-right font-medium px-4 py-3">Доступно /30</th>
|
||||
<th className="text-left font-medium px-4 py-3">Использование</th>
|
||||
<th className="text-left font-medium px-4 py-3">Назначение</th>
|
||||
<th className="w-10 px-3 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{displayPools.map((pool) => {
|
||||
const pct = pool.total > 0 ? Math.round((pool.allocated / pool.total) * 100) : 0
|
||||
return (
|
||||
<tr key={pool.id} className="hover:bg-muted/40 transition-colors">
|
||||
<td className="px-5 py-3 font-mono text-[13px] font-medium">{pool.name}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{pool.cidr}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{pool.allocated}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-muted-foreground">{pool.total - pool.allocated}</td>
|
||||
<td className="px-4 py-3 min-w-[140px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div className={`h-full rounded-full ${pct > 80 ? "bg-amber-500" : "bg-emerald-500"}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums w-8 text-right">{pct}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground text-xs">{pool.comment}</td>
|
||||
<td className="px-3 py-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={
|
||||
<Button variant="ghost" size="icon" className="size-7">
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
} />
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem><PencilIcon className="size-4" /> Редактировать</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" /> Удалить пул</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<GrePoolsDataGrid pools={displayPools} />
|
||||
|
||||
<div className="border-t px-5 py-4">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-3">Назначения по пулам</p>
|
||||
@@ -664,7 +515,7 @@ export default function GrePage() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</DataPageCard>
|
||||
)}
|
||||
|
||||
{/* RouterOS reference */}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { IpRangesDataGrid } from "@/components/data-grids/ip-ranges-data-grid"
|
||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { ipRanges as mockIpRanges } from "@/lib/data"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -16,6 +18,7 @@ export default function IpRangesPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
|
||||
@@ -25,6 +28,18 @@ export default function IpRangesPage() {
|
||||
return snapshot?.ipRanges ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return rows
|
||||
const q = search.toLowerCase()
|
||||
return rows.filter(
|
||||
(r) =>
|
||||
r.cidr.toLowerCase().includes(q) ||
|
||||
r.asn.toLowerCase().includes(q) ||
|
||||
r.country.toLowerCase().includes(q) ||
|
||||
r.filter.toLowerCase().includes(q),
|
||||
)
|
||||
}, [rows, search])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
@@ -60,57 +75,19 @@ export default function IpRangesPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
data={rows}
|
||||
isLoading={useEvoCatalog && loading && !snapshot}
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по CIDR, ASN…"
|
||||
searchKeys={["cidr", "asn", "country", "filter"]}
|
||||
columns={[
|
||||
{
|
||||
key: "cidr",
|
||||
label: "CIDR",
|
||||
render: (d) => <span className="font-mono font-medium">{d.cidr}</span>,
|
||||
},
|
||||
{
|
||||
key: "asn",
|
||||
label: "ASN",
|
||||
render: (d) => <span className="font-mono text-xs text-muted-foreground">{d.asn}</span>,
|
||||
},
|
||||
{
|
||||
key: "country",
|
||||
label: "Страна",
|
||||
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.country}</span>,
|
||||
},
|
||||
{
|
||||
key: "purpose",
|
||||
label: "Назначение",
|
||||
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.purpose}</span>,
|
||||
},
|
||||
{
|
||||
key: "filter",
|
||||
label: "Фильтр",
|
||||
render: (d) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updated",
|
||||
label: "Обновлён",
|
||||
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
|
||||
},
|
||||
{
|
||||
key: "enabled",
|
||||
label: "Статус",
|
||||
render: (d) => (
|
||||
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
|
||||
{d.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
countLabel={`${filtered.length} диапазонов`}
|
||||
/>
|
||||
<IpRangesDataGrid
|
||||
ipRanges={filtered}
|
||||
isLoading={useEvoCatalog && loading && !snapshot}
|
||||
pagination={useEvoCatalog}
|
||||
/>
|
||||
</DataPageCard>
|
||||
</div>
|
||||
</div>
|
||||
<FileImportDialog
|
||||
|
||||
+18
-151
@@ -2,6 +2,10 @@
|
||||
|
||||
import { useMemo, useState, useEffect } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-data-grid"
|
||||
import { OspfRoutesDataGrid, routeTypeClass } from "@/components/data-grids/ospf-routes-data-grid"
|
||||
import { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
@@ -375,13 +379,6 @@ function stateClass(state: OspfNeighbor["state"] | BfdSession["state"]) {
|
||||
return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25"
|
||||
}
|
||||
|
||||
function routeTypeClass(type: OspfRoute["type"]) {
|
||||
if (type === "O") return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25"
|
||||
if (type === "O IA") return "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/25"
|
||||
if (type === "O E1") return "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/25"
|
||||
return "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/25"
|
||||
}
|
||||
|
||||
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
|
||||
return (
|
||||
<span className={cn(
|
||||
@@ -978,60 +975,14 @@ function NeighborsTab({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/40">
|
||||
{["Роутер", "Интерфейс", "Сосед (Router ID)", "Область", "Состояние", "Cost", "Uptime", "Prio"].map(h => (
|
||||
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{neighbors.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Нет OSPF-соседей
|
||||
</td>
|
||||
</tr>
|
||||
) : neighbors.map(n => {
|
||||
const isHighlighted = selectedId === n.localRouter || selectedId === n.remoteRouter
|
||||
return (
|
||||
<tr key={n.id}
|
||||
onMouseEnter={() => setHighlightId(n.localRouter)}
|
||||
onMouseLeave={() => setHighlightId(null)}
|
||||
onClick={() => setSelectedId(prev => prev === n.localRouter ? null : n.localRouter)}
|
||||
className={cn(
|
||||
"transition-colors cursor-pointer",
|
||||
isHighlighted ? "bg-primary/5 hover:bg-primary/8" : "hover:bg-muted/30",
|
||||
)}>
|
||||
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{n.localLabel}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-muted-foreground whitespace-nowrap">{n.localIface}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono">{n.remoteLabel !== n.remoteRouterId ? n.remoteLabel : n.remoteRouterId}</span>
|
||||
{n.remoteLabel !== n.remoteRouterId && (
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{n.remoteRouterId}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono text-muted-foreground">{n.area}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium", stateClass(n.state))}>
|
||||
{n.state}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-center">{n.cost}</td>
|
||||
<td className="px-3 py-2.5 text-muted-foreground whitespace-nowrap tabular-nums">{n.uptime}</td>
|
||||
<td className="px-3 py-2.5 text-center font-mono">{n.priority}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<DataPageCard>
|
||||
<OspfNeighborsDataGrid
|
||||
neighbors={neighbors}
|
||||
selectedRouterId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
onHighlight={setHighlightId}
|
||||
/>
|
||||
</DataPageCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1052,36 +1003,9 @@ function RoutesTab({ routes }: { routes: OspfRoute[] }) {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/40">
|
||||
{["Назначение", "Тип", "Cost", "Следующий хоп", "Интерфейс", "Роутер", "Область"].map(h => (
|
||||
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{routes.map(r => (
|
||||
<tr key={r.id} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-3 py-2.5 font-mono">{r.destination}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium", routeTypeClass(r.type))}>
|
||||
{r.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-center">{r.cost}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-muted-foreground">{r.nextHop}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-muted-foreground">{r.via}</td>
|
||||
<td className="px-3 py-2.5 font-mono">{r.serverLabel}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-muted-foreground">{r.area}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<DataPageCard>
|
||||
<OspfRoutesDataGrid routes={routes} />
|
||||
</DataPageCard>
|
||||
<div className="flex items-center gap-5 flex-wrap px-1">
|
||||
<span className="text-xs text-muted-foreground">Типы:</span>
|
||||
{([
|
||||
@@ -1139,66 +1063,9 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
|
||||
)}
|
||||
|
||||
{sessions.length > 0 && (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/40">
|
||||
{[
|
||||
"Роутер", "Интерфейс", "Локальный", "Удалённый",
|
||||
"Состояние", "Uptime", "Tx / Rx", "Hold", "Mult",
|
||||
"Пакеты Rx", "Пакеты Tx", "Переходы",
|
||||
].map(h => (
|
||||
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{sessions.map(b => (
|
||||
<tr key={b.id} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-3 py-2.5 font-mono whitespace-nowrap">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span>{b.serverLabel}</span>
|
||||
{b.multihop && (
|
||||
<Chip color="bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20">multihop</Chip>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono text-muted-foreground whitespace-nowrap">{b.iface || "—"}</td>
|
||||
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{b.localAddr}</td>
|
||||
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{b.remoteAddr}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium", stateClass(b.state))}>
|
||||
{b.state}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-muted-foreground whitespace-nowrap tabular-nums">
|
||||
{b.uptime ?? "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-muted-foreground whitespace-nowrap">
|
||||
{fmtMs(b.interval)} / {fmtMs(b.rxInterval)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-muted-foreground whitespace-nowrap">
|
||||
{fmtMs(b.holdTime)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-center">{b.multiplier}</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-right text-muted-foreground">
|
||||
{b.packetsRx.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-right text-muted-foreground">
|
||||
{b.packetsTx.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-center">
|
||||
<span className={cn(b.stateChanges > 3 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground")}>
|
||||
{b.stateChanges}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<DataPageCard>
|
||||
<OspfBfdDataGrid sessions={sessions} />
|
||||
</DataPageCard>
|
||||
)}
|
||||
|
||||
{sessions.length > 0 && (
|
||||
|
||||
+28
-123
@@ -4,6 +4,16 @@ import { useEffect, useRef, useState, useMemo, useCallback } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import {
|
||||
ProbesScheduleDataGrid,
|
||||
type SchedRule,
|
||||
type SchedType,
|
||||
} from "@/components/data-grids/probes-schedule-data-grid"
|
||||
import {
|
||||
ProbesSpeedProbesDataGrid,
|
||||
type SpeedProbeApiRow,
|
||||
} from "@/components/data-grids/probes-speed-probes-data-grid"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
|
||||
@@ -41,13 +51,8 @@ interface DiagTest {
|
||||
source?: "demo" | "live"
|
||||
}
|
||||
|
||||
type SchedType = "ping" | "bandwidth" | "both"
|
||||
// SchedRule imported from probes-schedule-data-grid
|
||||
|
||||
interface SchedRule {
|
||||
id: string; srcId: string; tunnelId: string; type: SchedType
|
||||
intervalMin: number; enabled: boolean
|
||||
lastRun: string | null; nextRunMin: number | null
|
||||
}
|
||||
|
||||
// ─── tool metadata ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -98,22 +103,7 @@ interface BackendServerRow {
|
||||
latency?: number | null
|
||||
}
|
||||
|
||||
interface SpeedProbeApiRow {
|
||||
id: string
|
||||
srcServerId: string
|
||||
dstServerId: string
|
||||
srcInterface: string
|
||||
dstInterface: string
|
||||
protocol: string
|
||||
direction: string
|
||||
durationSec: string
|
||||
enabled: boolean
|
||||
lastRunAt: string | null
|
||||
lastTxAvgMbps: number | null
|
||||
lastRxAvgMbps: number | null
|
||||
lastStatus: string | null
|
||||
lastError: string | null
|
||||
}
|
||||
// SpeedProbeApiRow imported from probes-speed-probes-data-grid
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -466,53 +456,9 @@ function ScheduleSpeedProbesLive({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Card className="overflow-hidden">
|
||||
{rows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 gap-2 text-muted-foreground">
|
||||
<ClockIcon className="size-7 opacity-20" />
|
||||
<p className="text-sm">Нет записей speed-test в мониторинге</p>
|
||||
<p className="text-xs text-muted-foreground/70 max-w-md text-center">
|
||||
Настраиваются через API <code className="text-[11px]">PUT /api/uptime/speed-probes</code> или связанный UI.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_1fr_80px_90px_80px_1fr] gap-2 items-center px-4 py-2 bg-muted/30 border-b text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
<span>Источник</span>
|
||||
<span>Назначение</span>
|
||||
<span>Протокол</span>
|
||||
<span>Сек</span>
|
||||
<span>Вкл</span>
|
||||
<span>Последний запуск</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border/60">
|
||||
{rows.map(r => (
|
||||
<div key={r.id} className={cn(
|
||||
"grid grid-cols-[1fr_1fr_80px_90px_80px_1fr] gap-2 items-center px-4 py-2.5 text-xs",
|
||||
!r.enabled && "opacity-50",
|
||||
)}>
|
||||
<span className="truncate font-mono">{name(r.srcServerId)}{r.srcInterface ? ` · ${r.srcInterface}` : ""}</span>
|
||||
<span className="truncate font-mono">{name(r.dstServerId)}{r.dstInterface ? ` · ${r.dstInterface}` : ""}</span>
|
||||
<span>{r.protocol.toUpperCase()}</span>
|
||||
<span className="font-mono">{r.durationSec}s</span>
|
||||
<span>{r.enabled ? "да" : "нет"}</span>
|
||||
<span className="text-muted-foreground truncate">
|
||||
{r.lastRunAt ?? "—"}
|
||||
{r.lastStatus === "done" && r.lastTxAvgMbps != null && (
|
||||
<span className="text-emerald-600 dark:text-emerald-400 ml-1">
|
||||
TX≈{r.lastTxAvgMbps.toFixed(1)} RX≈{(r.lastRxAvgMbps ?? 0).toFixed(1)} Mb/s
|
||||
</span>
|
||||
)}
|
||||
{r.lastStatus === "error" && r.lastError && (
|
||||
<span className="text-destructive ml-1 truncate">{r.lastError}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<DataPageCard>
|
||||
<ProbesSpeedProbesDataGrid rows={rows} serverName={name} />
|
||||
</DataPageCard>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Данные из коллектора uptime (та же БД, что и дашборд). Редактирование — через настройки мониторинга / API.
|
||||
</p>
|
||||
@@ -553,63 +499,22 @@ function ScheduleTab({
|
||||
}
|
||||
}, [addSrc, addTun, tunnelsForServer])
|
||||
|
||||
const typeLabel: Record<SchedType, string> = { ping: "Ping", bandwidth: "BW-тест", both: "Ping + BW" }
|
||||
const tunnelName = (srcId: string, tunnelId: string) =>
|
||||
tunnelsForServer(srcId).find((t) => t.id === tunnelId)?.name
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Card className="overflow-hidden">
|
||||
{rules.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 gap-2 text-muted-foreground">
|
||||
<ClockIcon className="size-7 opacity-20" />
|
||||
<p className="text-sm">Нет правил расписания</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[40px_1fr_140px_80px_100px_1fr_auto] gap-2 items-center px-4 py-2 bg-muted/30 border-b text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
<span />
|
||||
<span>Туннель</span>
|
||||
<span>Сервер</span>
|
||||
<span>Тип</span>
|
||||
<span>Интервал</span>
|
||||
<span>Последний / следующий</span>
|
||||
<span />
|
||||
</div>
|
||||
<div className="divide-y divide-border/60">
|
||||
{rules.map(rule => {
|
||||
const src = serverOptions.find(s => s.id === rule.srcId)
|
||||
const tun = tunnelsForServer(rule.srcId).find(t => t.id === rule.tunnelId)
|
||||
return (
|
||||
<div key={rule.id} className={cn(
|
||||
"grid grid-cols-[40px_1fr_140px_80px_100px_1fr_auto] gap-2 items-center px-4 py-2.5 hover:bg-muted/20 transition-colors",
|
||||
!rule.enabled && "opacity-50",
|
||||
)}>
|
||||
<FormToggle checked={rule.enabled}
|
||||
onChange={v => setRules(p => p.map(r => r.id === rule.id ? { ...r, enabled: v } : r))} />
|
||||
<code className="font-mono text-xs truncate">{tun?.name ?? rule.tunnelId}</code>
|
||||
<span className="text-xs text-muted-foreground truncate">{src?.name ?? rule.srcId}</span>
|
||||
<span className={cn("text-[10px] px-1.5 py-0.5 rounded border font-medium w-fit",
|
||||
rule.type === "ping" ? "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20"
|
||||
: rule.type === "bandwidth" ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
|
||||
)}>{typeLabel[rule.type]}</span>
|
||||
<span className="text-xs text-muted-foreground">каждые {rule.intervalMin} мин</span>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2 min-w-0">
|
||||
{rule.lastRun && <span className="truncate">{rule.lastRun}</span>}
|
||||
{rule.nextRunMin != null && rule.enabled && (
|
||||
<span className="text-sky-600 dark:text-sky-400 shrink-0">· через {rule.nextRunMin} мин</span>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setRules(p => p.filter(r => r.id !== rule.id))}
|
||||
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-red-500 hover:bg-red-500/10 transition-colors">
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<DataPageCard>
|
||||
<ProbesScheduleDataGrid
|
||||
rules={rules}
|
||||
serverOptions={serverOptions}
|
||||
tunnelName={tunnelName}
|
||||
onToggleEnabled={(id, enabled) =>
|
||||
setRules((p) => p.map((r) => (r.id === id ? { ...r, enabled } : r)))
|
||||
}
|
||||
onDelete={(id) => setRules((p) => p.filter((r) => r.id !== id))}
|
||||
/>
|
||||
</DataPageCard>
|
||||
{showAdd ? (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="px-4 py-3 border-b flex items-center gap-2 text-sm font-medium">
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import {
|
||||
RecursiveRoutesDataGrid,
|
||||
type RecursiveRouteGroup,
|
||||
inferCountry,
|
||||
} from "@/components/data-grids/recursive-routes-data-grid"
|
||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -12,7 +17,7 @@ import { Flag } from "@/components/flag"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { servers as mockServers, type Server } from "@/lib/data"
|
||||
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, ChevronDownIcon, ChevronRightIcon, AlertCircleIcon, CheckIcon } from "lucide-react"
|
||||
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
|
||||
interface BackendServer {
|
||||
@@ -35,25 +40,6 @@ interface GatewayOption {
|
||||
status: "up" | "down"
|
||||
}
|
||||
|
||||
const INFER_COUNTRIES = [
|
||||
{ code: "RU", keys: ["MSK", "SPB", "RTK", "MTS", "VPSVILLE", "IHOR"] },
|
||||
{ code: "SE", keys: ["SWE", "STO"] },
|
||||
{ code: "FI", keys: ["HEL", "FIN"] },
|
||||
{ code: "DE", keys: ["FRA", "GER", "DE"] },
|
||||
{ code: "NL", keys: ["AMS", "NLD", "NL"] },
|
||||
{ code: "SG", keys: ["SGP", "SIN", "SG"] },
|
||||
{ code: "TR", keys: ["TUR", "TR"] },
|
||||
{ code: "US", keys: ["USA", "US", "NYC", "LAX"] },
|
||||
]
|
||||
|
||||
function inferCountry(name: string): string | null {
|
||||
const upper = name.toUpperCase()
|
||||
for (const c of INFER_COUNTRIES) {
|
||||
if (c.keys.some(k => upper.includes(k))) return c.code
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const COUNTRY_OPTIONS = [
|
||||
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
|
||||
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
|
||||
@@ -83,13 +69,7 @@ interface RecursiveRouteRow {
|
||||
country: string
|
||||
}
|
||||
|
||||
interface RouteGroup {
|
||||
key: string
|
||||
dstAddress: string
|
||||
routingTable: string
|
||||
comment: string
|
||||
endpoints: RecursiveRouteRow[]
|
||||
}
|
||||
interface RouteGroup extends RecursiveRouteGroup {}
|
||||
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -149,105 +129,6 @@ const emptyForm = (): RouteForm => ({
|
||||
endpoints: [newEndpoint()],
|
||||
})
|
||||
|
||||
function RouteGroupRows({
|
||||
group, expanded, onToggle, onEdit, onDelete,
|
||||
}: {
|
||||
group: RouteGroup
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const [confirmDel, setConfirmDel] = useState(false)
|
||||
const bestDistance = Math.min(...group.endpoints.map(ep => ep.distance))
|
||||
const sorted = [...group.endpoints].sort((a, b) => a.distance - b.distance)
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className={cn(
|
||||
"hover:bg-muted/40 transition-colors cursor-pointer group",
|
||||
expanded && "bg-muted/30",
|
||||
)}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-start gap-2">
|
||||
{expanded
|
||||
? <ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{group.dstAddress}</p>
|
||||
<p className="text-xs font-mono text-muted-foreground">{group.comment || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{sorted.map((ep, idx) => {
|
||||
const code = ep.country || inferCountry(ep.gateway)
|
||||
return (
|
||||
<div key={ep.id} className="flex items-center gap-1.5 text-[11px] font-mono">
|
||||
<span className={cn(
|
||||
"size-1.5 rounded-full shrink-0",
|
||||
idx === 0 ? "bg-emerald-500" : "bg-sky-500",
|
||||
)} />
|
||||
{code ? <Flag code={code} size={14} className="shrink-0" /> : <span className="text-[10px] text-muted-foreground w-3.5 text-center shrink-0">?</span>}
|
||||
<span className="font-semibold text-sky-600 dark:text-sky-400 truncate min-w-0">{ep.gateway}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs tabular-nums">{group.endpoints.length}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">d{bestDistance}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{group.routingTable || "main"}</td>
|
||||
<td className="px-3 py-3" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button size="sm" variant="ghost" className="size-7 p-0 text-muted-foreground hover:text-foreground" onClick={onEdit}><PencilIcon className="size-3.5" /></Button>
|
||||
<Button size="sm" variant="ghost" className={cn("size-7 p-0 transition-colors", confirmDel ? "text-destructive bg-destructive/10 hover:bg-destructive/20" : "text-muted-foreground hover:text-destructive")} onClick={() => { if (!confirmDel) setConfirmDel(true); else onDelete() }} onBlur={() => setConfirmDel(false)}>
|
||||
{confirmDel ? <AlertCircleIcon className="size-3.5" /> : <TrashIcon className="size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="bg-muted/20">
|
||||
<td colSpan={6} className="px-8 py-5 border-b border-border/50">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
|
||||
<span className="text-muted-foreground">Route: <span className="font-mono text-foreground">{group.dstAddress}</span></span>
|
||||
<span className="text-muted-foreground">Table: <span className="font-mono text-foreground">{group.routingTable || "main"}</span></span>
|
||||
{group.comment && <span className="text-muted-foreground italic">{group.comment}</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5">
|
||||
{sorted.map((ep, idx) => (
|
||||
<div key={ep.id} className="rounded-lg border border-border bg-background px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(ep.country || inferCountry(ep.gateway)) && (
|
||||
<Flag code={ep.country || inferCountry(ep.gateway) || ""} size={16} />
|
||||
)}
|
||||
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide">Endpoint {idx + 1}</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-mono">distance: {ep.distance}</span>
|
||||
</div>
|
||||
<p className="mt-1.5 font-mono text-sm break-all leading-tight">{ep.gateway}</p>
|
||||
<div className="mt-1.5 text-[11px] text-muted-foreground flex items-center gap-3">
|
||||
<span>scope: {ep.scope ?? "—"}</span>
|
||||
<span>t.scope: {ep.targetScope ?? "—"}</span>
|
||||
<span>check: {ep.checkGateway || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function EndpointCountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const [query, setQuery] = useState("")
|
||||
const q = query.trim().toUpperCase()
|
||||
@@ -794,37 +675,13 @@ export default function RecursiveRoutesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||
<th className="text-left font-medium px-5 py-3">Route / Comment</th>
|
||||
<th className="text-left font-medium px-4 py-3">Gateways</th>
|
||||
<th className="text-left font-medium px-4 py-3">EP</th>
|
||||
<th className="text-left font-medium px-4 py-3">Priority</th>
|
||||
<th className="text-left font-medium px-4 py-3">Table</th>
|
||||
<th className="w-10 px-3 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{groupedRoutes.map((g) => (
|
||||
<RouteGroupRows
|
||||
key={g.key}
|
||||
group={g}
|
||||
expanded={expandedGroupKey === g.key}
|
||||
onToggle={() => setExpandedGroupKey(prev => prev === g.key ? null : g.key)}
|
||||
onEdit={() => openEdit(g)}
|
||||
onDelete={() => setRows(prev => prev.filter(r => groupKeyOf(r) !== g.key))}
|
||||
<RecursiveRoutesDataGrid
|
||||
groups={groupedRoutes.map((g) => ({ ...g, id: g.key }))}
|
||||
expandedKey={expandedGroupKey}
|
||||
onExpandedChange={setExpandedGroupKey}
|
||||
onEdit={openEdit}
|
||||
onDelete={(g) => setRows((prev) => prev.filter((r) => groupKeyOf(r) !== g.key))}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{groupedRoutes.length === 0 && (
|
||||
<div className="p-8 text-center text-sm text-muted-foreground">
|
||||
Нет маршрутов в БД для этого сервера. Нажми "Router => DB" для загрузки.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={openCreate}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
|
||||
<PlusIcon className="size-3.5" />
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
|
||||
import Link from "next/link"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { RouteOptimizerWanMatrixDataGrid } from "@/components/data-grids/route-optimizer-wan-matrix-data-grid"
|
||||
import { RouteOptimizerFullRoutesDataGrid } from "@/components/data-grids/route-optimizer-full-routes-data-grid"
|
||||
import { RouteOptimizerCommRecsDataGrid } from "@/components/data-grids/route-optimizer-comm-recs-data-grid"
|
||||
import { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -266,327 +271,6 @@ function SettingRow({ label, unit, children }: { label: string; unit?: string; c
|
||||
)
|
||||
}
|
||||
|
||||
// ─── WAN Matrix table ─────────────────────────────────────────────────────────
|
||||
// Rows = WANs, Columns = JHs, cells show ping / bw / score
|
||||
|
||||
function WanMatrix({ home, legs, jumpHosts, pw: _pw }: {
|
||||
home: HomeRouter
|
||||
legs: WanJhLeg[]
|
||||
jumpHosts: JumpHost[]
|
||||
pw: number
|
||||
}) {
|
||||
// find best leg overall
|
||||
const bestScore = legs.length ? Math.max(...legs.map(l => l.score)) : 0
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-[11px] text-muted-foreground bg-muted/20">
|
||||
<th className="text-left font-medium px-4 py-2 w-[160px]">WAN-аплинк</th>
|
||||
<th className="text-left font-medium px-3 py-2">ISP / IP</th>
|
||||
<th className="text-right font-medium px-3 py-2">Макс. полоса</th>
|
||||
{jumpHosts.map(jh => (
|
||||
<th key={jh.id} className="text-center font-medium px-3 py-2 min-w-[130px]">
|
||||
<div>{jh.label}</div>
|
||||
<div className="font-mono font-normal text-[10px] opacity-60 flex items-center justify-center gap-1">
|
||||
<Flag code={jh.country} />
|
||||
{jh.site} · {jh.ip}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{home.wans.map(wan => (
|
||||
<tr key={wan.id} className="hover:bg-muted/30 transition-colors">
|
||||
{/* WAN name */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<WifiIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<div>
|
||||
<p className="font-mono text-xs font-semibold">{wan.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{wan.iface}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{/* ISP */}
|
||||
<td className="px-3 py-3">
|
||||
<p className="text-xs font-medium">{wan.isp}</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">{wan.ip}</p>
|
||||
</td>
|
||||
{/* Max bandwidth */}
|
||||
<td className="px-3 py-3 text-right">
|
||||
<p className="font-mono text-xs">↓{wan.maxDl}</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">↑{wan.maxUl} Мбит</p>
|
||||
</td>
|
||||
{/* Per-JH cells */}
|
||||
{jumpHosts.map(jh => {
|
||||
const leg = legs.find(l => l.wanId === wan.id && l.jhId === jh.id)
|
||||
if (!leg) return <td key={jh.id} className="px-3 py-3 text-center text-muted-foreground text-xs">—</td>
|
||||
const isBest = leg.score === bestScore
|
||||
return (
|
||||
<td key={jh.id} className={cn(
|
||||
"px-3 py-3 text-center",
|
||||
isBest && "bg-emerald-500/5",
|
||||
)}>
|
||||
<div className={cn(
|
||||
"flex flex-col items-center gap-0.5 rounded-md px-2 py-1.5 transition-colors",
|
||||
isBest
|
||||
? "border border-emerald-500/20 bg-emerald-500/8"
|
||||
: "border border-transparent",
|
||||
)}>
|
||||
{isBest && (
|
||||
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 mb-0.5">
|
||||
★ ЛУЧШИЙ
|
||||
</span>
|
||||
)}
|
||||
<span className={cn("font-mono text-xs font-semibold",
|
||||
leg.pingMs < 10 ? "text-emerald-600 dark:text-emerald-400"
|
||||
: leg.pingMs < 25 ? "text-foreground"
|
||||
: "text-amber-600 dark:text-amber-400"
|
||||
)}>
|
||||
{leg.pingMs} мс
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
↓{leg.dlMbps} ↑{leg.ulMbps}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-[10px] font-mono text-foreground/70">
|
||||
score {leg.score}
|
||||
</span>
|
||||
{leg.loss > 0 && <LossChip loss={leg.loss} />}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Full routes table ────────────────────────────────────────────────────────
|
||||
|
||||
function FullRoutesTable({ routes, bestId }: { routes: FullRoute[]; bestId?: string }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const visible = expanded ? routes : routes.slice(0, 5)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-[11px] text-muted-foreground bg-muted/20">
|
||||
<th className="text-left font-medium px-4 py-2"># Маршрут</th>
|
||||
<th className="text-left font-medium px-3 py-2">WAN → JH</th>
|
||||
<th className="text-left font-medium px-3 py-2">JH → Exit</th>
|
||||
<th className="text-center font-medium px-3 py-2">Ping (итого)</th>
|
||||
<th className="text-center font-medium px-3 py-2">BW (мин)</th>
|
||||
<th className="text-center font-medium px-3 py-2">Score</th>
|
||||
<th className="text-center font-medium px-3 py-2">P(opt)</th>
|
||||
<th className="text-center font-medium px-3 py-2">Conf.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{visible.map((r, i) => {
|
||||
const isBest = r.id === bestId || i === 0
|
||||
const totalPing = r.hw.pingMs + r.je.pingMs
|
||||
const minDl = Math.min(r.hw.dlMbps, r.je.dlMbps)
|
||||
const minUl = Math.min(r.hw.ulMbps, r.je.ulMbps)
|
||||
return (
|
||||
<tr key={r.id} className={cn(
|
||||
"hover:bg-muted/30 transition-colors",
|
||||
isBest && "bg-emerald-500/5",
|
||||
)}>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-mono text-muted-foreground w-4">{i + 1}</span>
|
||||
{isBest && (
|
||||
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-1.5 py-0.5 rounded">
|
||||
Лучший
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="font-mono font-semibold text-sky-600 dark:text-sky-400">{r.wan.name}</span>
|
||||
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">{r.jh.label}</div>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">{r.hw.pingMs} мс · ↓{r.hw.dlMbps} ↑{r.hw.ulMbps}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<div>
|
||||
<div className="flex items-center gap-1 font-medium">
|
||||
<Flag code={r.exit.country} />
|
||||
{r.exit.label}
|
||||
<span className="text-[10px] text-muted-foreground">({r.exit.site})</span>
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">{r.je.pingMs} мс · ↓{r.je.dlMbps} ↑{r.je.ulMbps}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={cn("px-3 py-2.5 text-center font-mono text-xs",
|
||||
totalPing < 40 ? "text-emerald-600 dark:text-emerald-400"
|
||||
: totalPing < 80 ? "text-amber-600 dark:text-amber-400"
|
||||
: "text-red-500"
|
||||
)}>
|
||||
{totalPing} мс
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center font-mono text-xs text-muted-foreground">
|
||||
<div>↓{minDl}</div>
|
||||
<div>↑{minUl}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center font-mono text-xs font-semibold">{r.score}</td>
|
||||
<td className="px-3 py-2.5 text-center"><ProbChip prob={r.probabilityOptimal} best={isBest} /></td>
|
||||
<td className="px-3 py-2.5 text-center"><ConfChip conf={r.confidence} /></td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{routes.length > 5 && (
|
||||
<button onClick={() => setExpanded(v => !v)}
|
||||
className="w-full py-2 text-xs text-muted-foreground hover:text-foreground transition-colors border-t flex items-center justify-center gap-1">
|
||||
{expanded
|
||||
? <><ChevronUpIcon className="size-3" />Свернуть</>
|
||||
: <><ChevronDownIcon className="size-3" />Показать все {routes.length} комбинаций</>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Community recs table ─────────────────────────────────────────────────────
|
||||
|
||||
function CommRecsTable({ recs, homeId, pinned, applied, applying, onPin, onApply, threshold: _threshold }: {
|
||||
recs: CommRec[]
|
||||
homeId: string
|
||||
pinned: Set<string>
|
||||
applied: Set<string>
|
||||
applying: Set<string>
|
||||
onPin: (k: string) => void
|
||||
onApply: (comm: string, homeId: string) => void
|
||||
threshold: number
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-[11px] text-muted-foreground bg-muted/20">
|
||||
<th className="text-left font-medium px-4 py-2">Community</th>
|
||||
<th className="text-left font-medium px-3 py-2">Текущий (WAN → JH → Exit)</th>
|
||||
<th className="text-left font-medium px-3 py-2">Рекомендуемый</th>
|
||||
<th className="text-center font-medium px-3 py-2">P(тек / рек)</th>
|
||||
<th className="text-right font-medium px-3 py-2">Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{recs.map((r, idx) => {
|
||||
const pinKey = `${homeId}::${r.community}`
|
||||
const isPinned = pinned.has(pinKey)
|
||||
const isApplied = applied.has(pinKey)
|
||||
const isApplying = applying.has(pinKey)
|
||||
const canApply = r.shouldSwitch && !isPinned && !isApplied
|
||||
|
||||
return (
|
||||
<tr key={`${homeId}::${r.community}::${idx}`} className={cn(
|
||||
"hover:bg-muted/30 transition-colors",
|
||||
r.shouldSwitch && !isPinned && !isApplied && "bg-amber-500/5",
|
||||
isApplied && "bg-emerald-500/5",
|
||||
)}>
|
||||
{/* community */}
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="font-mono text-xs font-medium">{r.community}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{r.communityName}</div>
|
||||
</td>
|
||||
|
||||
{/* current route */}
|
||||
<td className="px-3 py-2.5">
|
||||
{r.current ? (
|
||||
<div className="text-xs flex items-center gap-1 flex-wrap">
|
||||
<span className="font-mono font-medium text-sky-600 dark:text-sky-400">{r.current.wan}</span>
|
||||
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<span>{r.current.jh}</span>
|
||||
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">{r.current.exit}</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">({r.current.gateway})</span>
|
||||
</div>
|
||||
) : <span className="text-muted-foreground text-xs">—</span>}
|
||||
</td>
|
||||
|
||||
{/* recommended */}
|
||||
<td className="px-3 py-2.5">
|
||||
{r.recommended ? (
|
||||
<div className={cn("text-xs flex items-center gap-1 flex-wrap",
|
||||
r.shouldSwitch && !isPinned && "text-amber-600 dark:text-amber-400")}>
|
||||
<span className="font-mono font-medium">{r.recommended.wan}</span>
|
||||
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
|
||||
<span>{r.recommended.jh}</span>
|
||||
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
|
||||
<span>{r.recommended.exit}</span>
|
||||
{r.shouldSwitch && !isPinned && (
|
||||
<span className="ml-1 text-[10px] font-bold bg-amber-500/10 border border-amber-500/20 px-1.5 py-0.5 rounded">
|
||||
+{(r.recommended.prob ?? 0) - (r.current?.prob ?? 0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : <span className="text-muted-foreground text-xs">—</span>}
|
||||
</td>
|
||||
|
||||
{/* probability */}
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<ProbChip prob={r.current?.prob ?? 0} />
|
||||
<span className="text-muted-foreground text-[10px]">/</span>
|
||||
<ProbChip prob={r.recommended?.prob ?? 0} best={r.shouldSwitch && !isPinned} />
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* actions */}
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{isPinned && <PinIcon className="size-3 text-sky-500 fill-sky-500" />}
|
||||
<Button variant="outline" size="sm"
|
||||
className={cn("h-7 text-xs", isPinned && "text-sky-600 dark:text-sky-400 border-sky-500/30")}
|
||||
onClick={() => onPin(pinKey)}>
|
||||
<PinIcon className={cn("size-3", isPinned && "fill-current")} />
|
||||
{isPinned ? "Открепить" : "Закрепить"}
|
||||
</Button>
|
||||
{canApply && (
|
||||
<Button size="sm" className="h-7 text-xs" disabled={isApplying}
|
||||
onClick={() => onApply(r.community, homeId)}>
|
||||
{isApplying
|
||||
? <RefreshCwIcon className="size-3 animate-spin" />
|
||||
: <PlayIcon className="size-3" />}
|
||||
Применить
|
||||
</Button>
|
||||
)}
|
||||
{isApplied && (
|
||||
<span className="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
||||
<CheckCircleIcon className="size-3" />Применено
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Home Router card ─────────────────────────────────────────────────────────
|
||||
|
||||
type HomeTab = "wan-matrix" | "full-routes" | "bgp-community"
|
||||
@@ -669,18 +353,17 @@ function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying,
|
||||
|
||||
{/* Tab content */}
|
||||
{tab === "wan-matrix" && (
|
||||
<WanMatrix home={home} legs={wanJhLegs} jumpHosts={jumpHosts} pw={settings.pingWeight} />
|
||||
<RouteOptimizerWanMatrixDataGrid home={home} legs={wanJhLegs} jumpHosts={jumpHosts} />
|
||||
)}
|
||||
{tab === "full-routes" && (
|
||||
<FullRoutesTable routes={fullRoutes} bestId={bestRoute?.id} />
|
||||
<RouteOptimizerFullRoutesDataGrid routes={fullRoutes} bestId={bestRoute?.id} />
|
||||
)}
|
||||
{tab === "bgp-community" && (
|
||||
<CommRecsTable
|
||||
<RouteOptimizerCommRecsDataGrid
|
||||
recs={commRecs}
|
||||
homeId={home.id}
|
||||
pinned={pinned} applied={applied} applying={applying}
|
||||
onPin={onPin} onApply={onApply}
|
||||
threshold={settings.switchThreshold}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
@@ -1254,51 +937,14 @@ export default function RouteOptimizerPage() {
|
||||
: "нет данных"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/30">
|
||||
{["Интерфейс", "Cost", "Score", "Ping", "Speed (dl/ul)"].map((h) => (
|
||||
<th key={h} className="text-left px-3 py-1.5 font-medium text-muted-foreground">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{ospfPreviewError && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-2 text-destructive">
|
||||
Ошибка preview: {ospfPreviewError}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!ospfPreviewLoading && !ospfPreviewError && (ospfPreview?.interfaces.length ?? 0) === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-2 text-muted-foreground">
|
||||
Интерфейсы OSPF не найдены для выбранного сервера.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{(ospfPreview?.interfaces ?? []).map((row) => (
|
||||
<tr key={`${row.interface}-${row.currentCost}-${row.optimalCost}`}>
|
||||
<td className="px-3 py-1.5 font-mono">{row.interface}</td>
|
||||
<td className="px-3 py-1.5 font-mono">
|
||||
<span className="text-sky-600 dark:text-sky-400">{row.currentCost}</span>
|
||||
{" → "}
|
||||
<span className={row.currentCost === row.optimalCost
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-amber-600 dark:text-amber-400"}
|
||||
>
|
||||
{row.optimalCost}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 font-mono">{row.score}</td>
|
||||
<td className="px-3 py-1.5 font-mono">{row.pingMs}ms</td>
|
||||
<td className="px-3 py-1.5 font-mono">↓{row.dlMbps} / ↑{row.ulMbps}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<RouteOptimizerOspfPreviewDataGrid
|
||||
rows={(ospfPreview?.interfaces ?? []).map((row) => ({
|
||||
id: `${row.interface}-${row.currentCost}-${row.optimalCost}`,
|
||||
...row,
|
||||
}))}
|
||||
error={ospfPreviewError || null}
|
||||
loading={ospfPreviewLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ospfApplyResult && (
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useDataSource } from "@/lib/data-source"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
@@ -510,7 +511,7 @@ export default function ServersPage() {
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Card className="overflow-hidden py-0 gap-0">
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
segmented={{
|
||||
value: typeFilter,
|
||||
@@ -538,7 +539,7 @@ export default function ServersPage() {
|
||||
onDelete={handleDelete}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
/>
|
||||
</Card>
|
||||
</DataPageCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+18
-150
@@ -26,6 +26,9 @@ import {
|
||||
ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon, LoaderCircleIcon,
|
||||
DownloadIcon, UploadIcon,
|
||||
} from "lucide-react"
|
||||
import { SubusersDataGrid } from "@/components/data-grids/subusers-data-grid"
|
||||
import { SettingsAccessSummaryDataGrid } from "@/components/data-grids/settings-access-summary-data-grid"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
|
||||
@@ -568,100 +571,14 @@ function UserSheet({ open, user, onSave, onClose }: {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* table header */}
|
||||
{form.subUsers.length > 0 && (
|
||||
<div className="grid items-center gap-2 px-4 py-1.5 bg-muted/20 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"
|
||||
style={{ gridTemplateColumns: "1fr 130px 120px 90px 36px 32px" }}>
|
||||
<span>Логин / описание</span>
|
||||
<span>Пароль</span>
|
||||
<span>JH-серверы</span>
|
||||
<span>IP-клиента</span>
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* rows */}
|
||||
<div className="divide-y divide-border/60">
|
||||
{form.subUsers.length === 0 && !addSubOpen && (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
|
||||
<CableIcon className="size-6 opacity-20" />
|
||||
<p className="text-sm">Нет GRE-клиентов</p>
|
||||
<p className="text-xs opacity-60">Добавьте учётки для подключения устройств</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.subUsers.map(su => {
|
||||
const jhs = servers.filter(s => su.jhServerIds.includes(s.id))
|
||||
const revealed = revealedIds.has(su.id)
|
||||
return (
|
||||
<div key={su.id}
|
||||
className={cn(
|
||||
"grid items-center gap-2 px-4 py-2.5 hover:bg-muted/20 transition-colors",
|
||||
!su.active && "opacity-50",
|
||||
)}
|
||||
style={{ gridTemplateColumns: "1fr 130px 120px 90px 36px 32px" }}>
|
||||
|
||||
{/* login + description */}
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-mono font-medium truncate">{su.login}</p>
|
||||
{su.description && (
|
||||
<p className="text-[11px] text-muted-foreground truncate">{su.description}</p>
|
||||
)}
|
||||
{su.lastSeen && (
|
||||
<p className="text-[10px] text-muted-foreground/50">{su.lastSeen}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* password */}
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<span className="font-mono text-[11px] truncate flex-1">
|
||||
{revealed ? su.password : "••••••••••••"}
|
||||
</span>
|
||||
<button onClick={() => toggleReveal(su.id)}
|
||||
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors">
|
||||
{revealed
|
||||
? <EyeOffIcon className="size-3" />
|
||||
: <EyeIcon className="size-3" />}
|
||||
</button>
|
||||
<button onClick={() => navigator.clipboard.writeText(su.password).catch(() => {})}
|
||||
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors">
|
||||
<CopyIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* JH servers */}
|
||||
<div className="flex flex-wrap gap-1 min-w-0">
|
||||
{jhs.length === 0
|
||||
? <span className="text-[11px] text-muted-foreground/40">—</span>
|
||||
: jhs.map(jh => (
|
||||
<span key={jh.id} className="inline-flex items-center gap-1 text-[10px] font-medium
|
||||
bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20
|
||||
rounded px-1 py-0.5">
|
||||
<Flag code={jh.country} size={10} />
|
||||
{jh.name.split("-").slice(-1)[0]}
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* client IP */}
|
||||
<span className="font-mono text-[11px] text-muted-foreground truncate">
|
||||
{su.clientIp || "—"}
|
||||
</span>
|
||||
|
||||
{/* active toggle */}
|
||||
<FormToggle checked={su.active} onChange={() => toggleSubUser(su.id)} />
|
||||
|
||||
{/* delete */}
|
||||
<button onClick={() => removeSubUser(su.id)}
|
||||
className="text-muted-foreground/40 hover:text-destructive transition-colors flex justify-end">
|
||||
<TrashIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<SubusersDataGrid
|
||||
subUsers={form.subUsers}
|
||||
servers={servers}
|
||||
revealedIds={revealedIds}
|
||||
onToggleReveal={toggleReveal}
|
||||
onToggleActive={toggleSubUser}
|
||||
onRemove={removeSubUser}
|
||||
/>
|
||||
|
||||
{/* inline add form */}
|
||||
{addSubOpen ? (
|
||||
@@ -1561,66 +1478,17 @@ export default function SettingsPage() {
|
||||
</Card>
|
||||
|
||||
{/* access summary */}
|
||||
<Card className="overflow-hidden gap-0 py-0">
|
||||
<DataPageCard>
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
||||
<UserIcon className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium">Сводка прав доступа</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-[11px] text-muted-foreground bg-muted/30">
|
||||
<th className="text-left font-medium px-4 py-2">Пользователь</th>
|
||||
<th className="text-left font-medium px-4 py-2">Разделы</th>
|
||||
<th className="text-left font-medium px-4 py-2">Серверы</th>
|
||||
<th className="text-left font-medium px-4 py-2">Права записи</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{users.map(u => {
|
||||
const writeSections = u.role === "admin" ? ALL_SECTIONS : u.sections.filter(s => s.level === "write").map(s => s.section)
|
||||
const readSections = u.role === "admin" ? [] : u.sections.filter(s => s.level === "read").map(s => s.section)
|
||||
const accessServers = u.role === "admin" ? servers : servers.filter(s => u.servers.find(p => p.serverId === s.id && p.level !== "none"))
|
||||
return (
|
||||
<tr key={u.id} className="hover:bg-muted/20 transition-colors">
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<AvatarCircle avatar={u.avatar} active={u.active} />
|
||||
<span className="text-sm font-medium">{u.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-muted-foreground">
|
||||
{u.role === "admin"
|
||||
? <span className="text-violet-600 dark:text-violet-400 font-medium">Все ({ALL_SECTIONS.length})</span>
|
||||
: <span>{(readSections.length + writeSections.length)} из {ALL_SECTIONS.length}</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-muted-foreground">
|
||||
{u.role === "admin"
|
||||
? <span className="text-violet-600 dark:text-violet-400 font-medium">Все ({servers.length})</span>
|
||||
: <span>{accessServers.length} из {servers.length}</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{u.role === "admin"
|
||||
? <span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20">Полный доступ</span>
|
||||
: writeSections.length === 0
|
||||
? <span className="text-[10px] text-muted-foreground">Только просмотр</span>
|
||||
: writeSections.slice(0, 3).map(s => (
|
||||
<span key={s} className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">{s}</span>
|
||||
))
|
||||
}
|
||||
{u.role !== "admin" && writeSections.length > 3 && (
|
||||
<span className="text-[10px] text-muted-foreground">+{writeSections.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<SettingsAccessSummaryDataGrid
|
||||
users={users}
|
||||
servers={servers}
|
||||
allSectionsCount={ALL_SECTIONS.length}
|
||||
/>
|
||||
</DataPageCard>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
+24
-322
@@ -10,6 +10,12 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { Sparkline } from "@/components/sparkline"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import {
|
||||
UptimeResourcesDataGrid,
|
||||
type UptimeResourceRow,
|
||||
} from "@/components/data-grids/uptime-resources-data-grid"
|
||||
import { UptimeSpeedHistoryDataGrid } from "@/components/data-grids/uptime-speed-history-data-grid"
|
||||
import { PING_PROBE_WARN_RTT_MS } from "@/lib/ping-probe"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { servers as mockServers, pingProbes as INIT_PROBES, filters, type Server, type Filter } from "@/lib/data"
|
||||
@@ -854,74 +860,55 @@ function SortIcon({ k, sortKey, sortAsc }: { k: ResSortKey; sortKey: ResSortKey;
|
||||
type ResTypeFilter = "all" | "jump-host" | "exit-node" | "home-router"
|
||||
|
||||
function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerResource[]; serversList: Server[]; liveApi?: boolean }) {
|
||||
const [sortKey, setSortKey] = useState<ResSortKey>("name")
|
||||
const [sortAsc, setSortAsc] = useState(true)
|
||||
const [resSearch, setResSearch] = useState("")
|
||||
const [typeFilter, setTypeFilter] = useState<ResTypeFilter>("all")
|
||||
|
||||
const rows = useMemo(() => resources.map((r) => {
|
||||
const rows = useMemo((): UptimeResourceRow[] => resources.map((r) => {
|
||||
const hasData = r.hasData !== false
|
||||
const ramPct = hasData && r.ramTotal > 0 ? Math.round(r.ramUsed / r.ramTotal * 100) : 0
|
||||
const hddPct = hasData && r.hddTotal > 0 ? Math.round(r.hddUsed / r.hddTotal * 100) : 0
|
||||
return {
|
||||
...r,
|
||||
hasData,
|
||||
server: serversList.find(s => s.id === r.serverId),
|
||||
server: serversList.find(s => s.id === r.serverId)!,
|
||||
ramPct,
|
||||
hddPct,
|
||||
}
|
||||
}).filter(r => r.server !== undefined), [resources, serversList])
|
||||
}).filter(r => serversList.some(s => s.id === r.serverId)), [resources, serversList])
|
||||
|
||||
// KPI aggregates (только серверы с реальными сэмплами за окно)
|
||||
const onlineWithSamples = rows.filter(r => r.server!.status === "online" && r.hasData)
|
||||
const onlineWithSamples = rows.filter(r => r.server.status === "online" && r.hasData)
|
||||
const avgCpu = onlineWithSamples.length ? Math.round(onlineWithSamples.reduce((s, r) => s + r.cpu, 0) / onlineWithSamples.length) : 0
|
||||
const avgRam = onlineWithSamples.length ? Math.round(onlineWithSamples.reduce((s, r) => s + r.ramPct, 0) / onlineWithSamples.length) : 0
|
||||
const highCpu = rows.filter(r => r.server!.status === "online" && r.hasData && r.cpu >= 85).length
|
||||
const highRam = rows.filter(r => r.server!.status === "online" && r.hasData && r.ramPct >= 85).length
|
||||
const highHdd = rows.filter(r => r.server!.status === "online" && r.hasData && r.hddPct >= 85).length
|
||||
const highCpu = rows.filter(r => r.server.status === "online" && r.hasData && r.cpu >= 85).length
|
||||
const highRam = rows.filter(r => r.server.status === "online" && r.hasData && r.ramPct >= 85).length
|
||||
const highHdd = rows.filter(r => r.server.status === "online" && r.hasData && r.hddPct >= 85).length
|
||||
|
||||
// Alerts
|
||||
const alerts = useMemo(() =>
|
||||
rows.filter(r => r.server!.status === "online" && r.hasData && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)),
|
||||
rows.filter(r => r.server.status === "online" && r.hasData && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)),
|
||||
[rows],
|
||||
)
|
||||
|
||||
// Filtered + sorted
|
||||
const visible = useMemo(() => {
|
||||
let list = rows
|
||||
if (typeFilter !== "all") list = list.filter(r => r.server!.type === typeFilter)
|
||||
if (typeFilter !== "all") list = list.filter(r => r.server.type === typeFilter)
|
||||
if (resSearch.trim()) {
|
||||
const q = resSearch.toLowerCase()
|
||||
list = list.filter(r =>
|
||||
r.server!.name.toLowerCase().includes(q) ||
|
||||
r.server!.site.toLowerCase().includes(q) ||
|
||||
r.server.name.toLowerCase().includes(q) ||
|
||||
r.server.site.toLowerCase().includes(q) ||
|
||||
r.boardName.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
list = [...list].sort((a, b) => {
|
||||
let diff = 0
|
||||
switch (sortKey) {
|
||||
case "name": diff = a.server!.name.localeCompare(b.server!.name); break
|
||||
case "cpu": diff = a.cpu - b.cpu; break
|
||||
case "ram": diff = a.ramPct - b.ramPct; break
|
||||
case "hdd": diff = a.hddPct - b.hddPct; break
|
||||
case "uptime": diff = a.uptimeSeconds - b.uptimeSeconds; break
|
||||
case "temp": diff = (a.temp ?? -1) - (b.temp ?? -1); break
|
||||
}
|
||||
return sortAsc ? diff : -diff
|
||||
})
|
||||
return list
|
||||
}, [rows, typeFilter, resSearch, sortKey, sortAsc])
|
||||
|
||||
function toggleSort(k: ResSortKey) {
|
||||
if (sortKey === k) setSortAsc(v => !v)
|
||||
else { setSortKey(k); setSortAsc(false) } // default desc for metrics
|
||||
}
|
||||
}, [rows, typeFilter, resSearch])
|
||||
|
||||
function exportCsv() {
|
||||
const header = ["Сервер", "Тип", "Площадка", "CPU %", "RAM %", "RAM использ.", "RAM всего", "HDD %", "HDD использ.", "HDD всего", "Uptime", "Температура °C", "RouterOS"]
|
||||
const rowsCsv = visible.map(r => {
|
||||
const s = r.server!
|
||||
const s = r.server
|
||||
return [s.name, s.type, s.site, r.cpu, r.ramPct, fmtMB(r.ramUsed), fmtMB(r.ramTotal),
|
||||
r.hddPct, fmtMB(r.hddUsed), fmtMB(r.hddTotal), fmtUptime(r.uptimeSeconds),
|
||||
r.temp ?? "", s.os].join(",")
|
||||
@@ -954,7 +941,7 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
|
||||
<AlertDescription>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{alerts.map(r => {
|
||||
const s = r.server!
|
||||
const s = r.server
|
||||
const issues: string[] = []
|
||||
if (r.cpu >= 85) issues.push(`CPU ${r.cpu}%`)
|
||||
if (r.ramPct >= 85) issues.push(`RAM ${r.ramPct}%`)
|
||||
@@ -1035,195 +1022,9 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
|
||||
</div>
|
||||
|
||||
{/* ── Table ─────────────────────────────────────────────────────────── */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/30 text-xs text-muted-foreground font-medium">
|
||||
|
||||
{/* Sortable: name */}
|
||||
<th className="text-left px-5 py-3 cursor-pointer hover:text-foreground transition-colors select-none"
|
||||
onClick={() => toggleSort("name")}>
|
||||
<span className="flex items-center gap-0.5">
|
||||
Сервер <SortIcon k="name" sortKey={sortKey} sortAsc={sortAsc} />
|
||||
</span>
|
||||
</th>
|
||||
|
||||
<th className="text-left px-4 py-3 hidden md:table-cell whitespace-nowrap">Модель · ROS</th>
|
||||
|
||||
{/* Sortable: cpu */}
|
||||
<th className="text-left px-4 py-3 min-w-[160px] cursor-pointer hover:text-foreground transition-colors select-none"
|
||||
onClick={() => toggleSort("cpu")}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<CpuIcon className="size-3.5" />CPU
|
||||
<SortIcon k="cpu" sortKey={sortKey} sortAsc={sortAsc} />
|
||||
</span>
|
||||
</th>
|
||||
|
||||
{/* Sortable: ram */}
|
||||
<th className="text-left px-4 py-3 min-w-[175px] cursor-pointer hover:text-foreground transition-colors select-none"
|
||||
onClick={() => toggleSort("ram")}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<HardDriveIcon className="size-3.5" />RAM
|
||||
<SortIcon k="ram" sortKey={sortKey} sortAsc={sortAsc} />
|
||||
</span>
|
||||
</th>
|
||||
|
||||
{/* Sortable: hdd */}
|
||||
<th className="text-left px-4 py-3 min-w-[175px] cursor-pointer hover:text-foreground transition-colors select-none"
|
||||
onClick={() => toggleSort("hdd")}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<HardDriveIcon className="size-3.5" />Диск
|
||||
<SortIcon k="hdd" sortKey={sortKey} sortAsc={sortAsc} />
|
||||
</span>
|
||||
</th>
|
||||
|
||||
{/* Sortable: uptime */}
|
||||
<th className="text-left px-4 py-3 cursor-pointer hover:text-foreground transition-colors select-none"
|
||||
onClick={() => toggleSort("uptime")}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ClockIcon className="size-3.5" />Uptime
|
||||
<SortIcon k="uptime" sortKey={sortKey} sortAsc={sortAsc} />
|
||||
</span>
|
||||
</th>
|
||||
|
||||
{/* Sortable: temp */}
|
||||
<th className="text-left px-4 py-3 cursor-pointer hover:text-foreground transition-colors select-none"
|
||||
onClick={() => toggleSort("temp")}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ThermometerIcon className="size-3.5" />°C
|
||||
<SortIcon k="temp" sortKey={sortKey} sortAsc={sortAsc} />
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{visible.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center text-sm text-muted-foreground py-12">
|
||||
<SearchIcon className="size-6 mx-auto mb-2 opacity-20" />
|
||||
Ничего не найдено
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{visible.map(r => {
|
||||
const srv = r.server!
|
||||
const offline = srv.status !== "online"
|
||||
const hasSamples = r.hasData !== false
|
||||
const noMetrics = offline || !hasSamples
|
||||
const isCrit = !noMetrics && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)
|
||||
const cpuColor = r.cpu >= 85 ? "hsl(0 84% 60%)" : r.cpu >= 70 ? "hsl(38 92% 50%)" : "hsl(142 76% 36%)"
|
||||
return (
|
||||
<tr key={r.serverId} className={cn(
|
||||
"hover:bg-muted/30 transition-colors",
|
||||
offline && "opacity-50",
|
||||
isCrit && "bg-red-500/3",
|
||||
)}>
|
||||
|
||||
{/* Server */}
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isCrit && <AlertCircleIcon className="size-3.5 text-red-500 shrink-0" />}
|
||||
{!isCrit && <StatusDot status={srv.status} pulse={!offline} />}
|
||||
<Flag code={srv.country} size={16} />
|
||||
<span className="font-mono font-semibold">{srv.name}</span>
|
||||
<TypeChip type={srv.type} />
|
||||
<span className="text-xs text-muted-foreground hidden xl:inline">{srv.site}</span>
|
||||
{!offline && r.hasData === false && (
|
||||
<span className="text-[10px] rounded border border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400 px-1.5 py-0.5">
|
||||
нет данных
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Board + ROS */}
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-mono text-xs text-muted-foreground">{hasSamples ? r.boardName : "—"}</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">{srv.os}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* CPU */}
|
||||
<td className="px-4 py-3">
|
||||
{noMetrics
|
||||
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
|
||||
: (
|
||||
<div className="flex flex-col gap-1.5 min-w-[140px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("font-mono text-sm font-semibold tabular-nums w-10 shrink-0", resPctColor(r.cpu))}>
|
||||
{r.cpu}%
|
||||
</span>
|
||||
<MiniBar pct={r.cpu} className="flex-1" />
|
||||
</div>
|
||||
<Sparkline data={r.cpuHistory} width={120} height={18} color={cpuColor} filled />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* RAM */}
|
||||
<td className="px-4 py-3">
|
||||
{noMetrics
|
||||
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
|
||||
: (
|
||||
<div className="flex flex-col gap-1.5 min-w-[155px]">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className={cn("font-mono font-semibold", resPctColor(r.ramPct))}>{r.ramPct}%</span>
|
||||
<span className="text-muted-foreground/60 font-mono text-[10px]">
|
||||
{fmtMB(r.ramUsed)}/{fmtMB(r.ramTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<MiniBar pct={r.ramPct} />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* HDD */}
|
||||
<td className="px-4 py-3">
|
||||
{noMetrics
|
||||
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
|
||||
: (
|
||||
<div className="flex flex-col gap-1.5 min-w-[155px]">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className={cn("font-mono font-semibold", resPctColor(r.hddPct))}>{r.hddPct}%</span>
|
||||
<span className="text-muted-foreground/60 font-mono text-[10px]">
|
||||
{fmtMB(r.hddUsed)}/{fmtMB(r.hddTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<MiniBar pct={r.hddPct} />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Uptime */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{noMetrics ? (offline ? "—" : "—") : fmtUptime(r.uptimeSeconds)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Temp */}
|
||||
<td className="px-4 py-3">
|
||||
{r.temp !== undefined && !noMetrics ? (
|
||||
<span className={cn("font-mono text-sm font-semibold tabular-nums",
|
||||
r.temp >= 70 ? "text-red-600 dark:text-red-400"
|
||||
: r.temp >= 55 ? "text-amber-600 dark:text-amber-400"
|
||||
: "text-muted-foreground",
|
||||
)}>
|
||||
{r.temp}°C
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/30 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<DataPageCard>
|
||||
<UptimeResourcesDataGrid rows={visible} />
|
||||
</DataPageCard>
|
||||
|
||||
<p className="text-xs text-muted-foreground/40 text-center">
|
||||
{liveApi
|
||||
@@ -2478,106 +2279,7 @@ export default function UptimePage() {
|
||||
<span className="text-xs text-muted-foreground">{speedRuns.length} запусков</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/40 text-muted-foreground">
|
||||
{["Время", "Маршрут", "Параметры", "Статус", "TX avg", "RX avg", "Ping после BT"].map(h => (
|
||||
<th key={h} className="px-4 py-2.5 text-left font-medium whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{speedRuns.map((run) => {
|
||||
const src = allServers.find((s) => s.id === run.srcServerId)
|
||||
const dst = allServers.find((s) => s.id === run.dstServerId)
|
||||
const maxVal = Math.max(run.txAvgMbps, run.rxAvgMbps, 1)
|
||||
return (
|
||||
<tr key={run.id} className="hover:bg-muted/20 transition-colors">
|
||||
<td className="px-4 py-2.5 text-muted-foreground whitespace-nowrap tabular-nums font-mono">
|
||||
{new Date(run.startedAt).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit", day: "2-digit", month: "2-digit" })}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Flag code={src?.country ?? "UN"} size={13} />
|
||||
<span>{src?.name ?? run.srcServerId}</span>
|
||||
<ArrowRightIcon className="size-3 text-muted-foreground" />
|
||||
<Flag code={dst?.country ?? "UN"} size={13} />
|
||||
<span>{dst?.name ?? run.dstServerId}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
|
||||
{run.srcInterfaceAddress && run.dstInterfaceAddress
|
||||
? `${run.srcInterfaceAddress} → ${run.dstInterfaceAddress}`
|
||||
: "внутренние IP: auto/не указаны"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-muted-foreground whitespace-nowrap">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold bg-muted/60 border-border/60">
|
||||
{run.protocol.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span>{run.direction}</span>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span>{run.durationSec}s</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{run.status === "running" ? (
|
||||
<span className="inline-flex items-center gap-1 text-[var(--status-degraded-fg)]">
|
||||
<RefreshCwIcon className="size-3 animate-spin" />running
|
||||
</span>
|
||||
) : run.status === "error" ? (
|
||||
<span className="text-[var(--status-offline-fg)]">error</span>
|
||||
) : (
|
||||
<span className="text-[var(--status-online-fg)]">done</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 min-w-[120px]">
|
||||
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-full rounded-full bg-[var(--chart-tx)]"
|
||||
style={{ width: `${(run.txAvgMbps / maxVal) * 100}%` }} />
|
||||
</div>
|
||||
<span className="font-mono tabular-nums text-[var(--chart-tx)] font-medium whitespace-nowrap">
|
||||
{run.txAvgMbps} Мбит/с
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 min-w-[120px]">
|
||||
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-full rounded-full bg-[var(--chart-rx)]"
|
||||
style={{ width: `${(run.rxAvgMbps / maxVal) * 100}%` }} />
|
||||
</div>
|
||||
<span className="font-mono tabular-nums text-[var(--chart-rx)] font-medium whitespace-nowrap">
|
||||
{run.rxAvgMbps} Мбит/с
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono tabular-nums whitespace-nowrap">
|
||||
{run.status !== "done" ? "—" : run.afterBtPing?.error ? (
|
||||
<span className="text-[var(--status-offline-fg)]" title={run.afterBtPing.error}>
|
||||
ошибка
|
||||
</span>
|
||||
) : run.afterBtPing?.rttMs != null ? (
|
||||
<span className="text-violet-600 dark:text-violet-400">
|
||||
{run.afterBtPing.rttMs} мс
|
||||
{run.afterBtPing.lossPct != null && run.afterBtPing.lossPct > 0 && (
|
||||
<span className="text-amber-600 dark:text-amber-400"> · {run.afterBtPing.lossPct}%</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-600 dark:text-amber-400">
|
||||
timeout
|
||||
{run.afterBtPing?.lossPct != null && <span> · {run.afterBtPing.lossPct}%</span>}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<UptimeSpeedHistoryDataGrid runs={speedRuns} servers={allServers} />
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
+17
-139
@@ -4,17 +4,14 @@ import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { vxlanTunnels, servers } from "@/lib/data"
|
||||
import type { VxlanTunnel } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||
DropdownMenuItem, DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
SearchIcon, NetworkIcon, PlusIcon, MoreHorizontalIcon,
|
||||
Trash2Icon, PencilIcon, PowerIcon, CopyIcon, CheckIcon,
|
||||
NetworkIcon, PlusIcon, CopyIcon, CheckIcon,
|
||||
CodeXmlIcon, LayersIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
@@ -125,100 +122,7 @@ function ExportSheet({ open, tunnel, onClose }: {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Tunnel row ───────────────────────────────────────────────────────────────
|
||||
|
||||
function TunnelRow({
|
||||
tunnel,
|
||||
onExport,
|
||||
}: {
|
||||
tunnel: VxlanTunnel
|
||||
onExport: () => void
|
||||
}) {
|
||||
const srv = serverFor(tunnel.serverId)
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"grid grid-cols-[10px_1fr_1fr_auto_auto_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-3 items-center border-b last:border-b-0 hover:bg-muted/30 transition-colors",
|
||||
!tunnel.enabled && "opacity-50",
|
||||
)}>
|
||||
{/* status dot */}
|
||||
<span className={cn(
|
||||
"size-2 rounded-full shrink-0",
|
||||
tunnel.status === "up" ? "bg-emerald-500" : "bg-red-500",
|
||||
)} />
|
||||
|
||||
{/* name */}
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono font-medium text-sm truncate">{tunnel.name}</p>
|
||||
<p className="text-[11px] text-muted-foreground font-mono">VTEP: {tunnel.vtepIp}</p>
|
||||
</div>
|
||||
|
||||
{/* server */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
|
||||
{srv && <><Flag code={srv.country} size={12} /><span className="font-mono truncate">{srv.name}</span></>}
|
||||
</div>
|
||||
|
||||
{/* VNI */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">VNI</p>
|
||||
<p className="font-mono text-sm">{tunnel.vni}</p>
|
||||
</div>
|
||||
|
||||
{/* Port */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">Port</p>
|
||||
<p className="font-mono text-sm">{tunnel.dstPort}</p>
|
||||
</div>
|
||||
|
||||
{/* Remote VTEPs */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">Remote VTEP</p>
|
||||
<p className="font-mono text-sm">{tunnel.remoteVteps.length}</p>
|
||||
</div>
|
||||
|
||||
{/* ARP Proxy */}
|
||||
<span className={cn("text-[10px] font-mono px-1.5 py-0.5 rounded",
|
||||
tunnel.arpProxy ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : "bg-muted text-muted-foreground")}>
|
||||
ARP {tunnel.arpProxy ? "✓" : "✗"}
|
||||
</span>
|
||||
|
||||
{/* MAC learning */}
|
||||
<span className={cn("text-[10px] font-mono px-1.5 py-0.5 rounded",
|
||||
tunnel.macLearning ? "bg-sky-500/10 text-sky-600 dark:text-sky-400" : "bg-muted text-muted-foreground")}>
|
||||
MAC {tunnel.macLearning ? "✓" : "✗"}
|
||||
</span>
|
||||
|
||||
{/* Status badge */}
|
||||
<span className={cn(
|
||||
"text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap",
|
||||
tunnel.status === "up"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||
)}>
|
||||
{tunnel.status === "up" ? "UP" : "DOWN"}
|
||||
</span>
|
||||
|
||||
{/* menu */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={
|
||||
<Button variant="ghost" size="icon" className="size-7">
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
} />
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
|
||||
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem><PowerIcon className="size-4" />{tunnel.enabled ? "Отключить" : "Включить"}</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
||||
export default function VxlanPage() {
|
||||
const [search, setSearch] = useState("")
|
||||
const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null)
|
||||
@@ -284,45 +188,19 @@ export default function VxlanPage() {
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
|
||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||
placeholder="Поиск по имени, VNI, серверу…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по имени, VNI, серверу…"
|
||||
countLabel={`${filtered.length} туннелей`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} туннелей</span>
|
||||
</div>
|
||||
|
||||
{/* header */}
|
||||
<div className="grid grid-cols-[10px_1fr_1fr_auto_auto_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20">
|
||||
<span />
|
||||
<span>Имя / VTEP IP</span>
|
||||
<span>Сервер</span>
|
||||
<span>VNI</span>
|
||||
<span>Port</span>
|
||||
<span>Remote</span>
|
||||
<span />
|
||||
<span />
|
||||
<span>Статус</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||||
<NetworkIcon className="size-10 mb-3 opacity-20" />
|
||||
<p className="text-sm font-medium">VXLAN туннели не найдены</p>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((t) => (
|
||||
<TunnelRow key={t.id} tunnel={t} onExport={() => setExportTunnel(t)} />
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
<VxlanDataGrid
|
||||
tunnels={filtered}
|
||||
servers={servers}
|
||||
onExport={setExportTunnel}
|
||||
/>
|
||||
</DataPageCard>
|
||||
|
||||
{/* Reference */}
|
||||
<Card>
|
||||
|
||||
+21
-237
@@ -2,36 +2,29 @@
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { servers } from "@/lib/data"
|
||||
import type { WireGuardInterface, WireGuardPeer } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import type { WireGuardInterface } from "@/lib/data"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import {
|
||||
WireguardDataGrid,
|
||||
type WgIfaceWithServer,
|
||||
} from "@/components/data-grids/wireguard-data-grid"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||
DropdownMenuItem, DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
ShieldCheckIcon, PlusIcon, SearchIcon, KeyRoundIcon,
|
||||
ChevronDownIcon, ChevronRightIcon, MoreHorizontalIcon,
|
||||
PencilIcon, Trash2Icon, PowerIcon, CopyIcon, CheckIcon,
|
||||
CodeXmlIcon, UsersIcon, ActivityIcon, ArrowDownIcon, ArrowUpIcon,
|
||||
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
|
||||
CodeXmlIcon, UsersIcon, ActivityIcon,
|
||||
CopyIcon, CheckIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
// ─── collect all WireGuard interfaces from all servers ────────────────────────
|
||||
|
||||
interface WgIfaceWithServer extends WireGuardInterface {
|
||||
serverId: string
|
||||
serverName: string
|
||||
serverCountry: string
|
||||
}
|
||||
|
||||
function collectInterfaces(): WgIfaceWithServer[] {
|
||||
const result: WgIfaceWithServer[] = []
|
||||
for (const srv of servers) {
|
||||
@@ -49,19 +42,6 @@ function collectInterfaces(): WgIfaceWithServer[] {
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtBytes(n: number | undefined): string {
|
||||
if (!n) return "—"
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)} ГБ`
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)} КБ`
|
||||
return `${n} Б`
|
||||
}
|
||||
|
||||
function truncKey(key: string): string {
|
||||
if (key.length <= 20) return key
|
||||
return `${key.slice(0, 8)}…${key.slice(-8)}`
|
||||
}
|
||||
|
||||
// ─── RSC generator ────────────────────────────────────────────────────────────
|
||||
|
||||
function generateWgRsc(iface: WgIfaceWithServer): string {
|
||||
@@ -90,161 +70,6 @@ function generateWgRsc(iface: WgIfaceWithServer): string {
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
// ─── Peer row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function PeerRow({ peer }: { peer: WireGuardPeer }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-4 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20">
|
||||
{/* public key */}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
|
||||
{truncKey(peer.publicKey)}
|
||||
</span>
|
||||
</div>
|
||||
{/* allowed IPs */}
|
||||
<div className="font-mono text-muted-foreground truncate">
|
||||
{peer.allowedIps.join(", ")}
|
||||
</div>
|
||||
{/* handshake */}
|
||||
<span className={cn(
|
||||
"font-mono text-[11px] whitespace-nowrap",
|
||||
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
|
||||
)}>
|
||||
{peer.latestHandshake ?? "нет рукопожатия"}
|
||||
</span>
|
||||
{/* rx / tx */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<ArrowDownIcon className="size-3 text-emerald-500" />{fmtBytes(peer.transferRx)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<ArrowUpIcon className="size-3 text-blue-400" />{fmtBytes(peer.transferTx)}
|
||||
</span>
|
||||
</div>
|
||||
{/* endpoint */}
|
||||
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Interface card ───────────────────────────────────────────────────────────
|
||||
|
||||
function IfaceRow({
|
||||
iface,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
onExport,
|
||||
}: {
|
||||
iface: WgIfaceWithServer
|
||||
expanded: boolean
|
||||
onToggleExpand: () => void
|
||||
onExport: () => void
|
||||
}) {
|
||||
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
|
||||
|
||||
return (
|
||||
<div className={cn("border-b last:border-b-0", !iface.enabled && "opacity-50")}>
|
||||
<div
|
||||
className="grid grid-cols-[20px_1fr_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-3 items-center hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
{/* expand */}
|
||||
<button className="text-muted-foreground" onClick={(e) => { e.stopPropagation(); onToggleExpand() }}>
|
||||
{expanded
|
||||
? <ChevronDownIcon className="size-3.5" />
|
||||
: <ChevronRightIcon className="size-3.5" />}
|
||||
</button>
|
||||
|
||||
{/* name + server */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"size-2 rounded-full shrink-0",
|
||||
iface.status === "up" ? "bg-emerald-500 animate-pulse" : "bg-red-500",
|
||||
)} />
|
||||
<span className="font-mono font-semibold text-sm">{iface.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
|
||||
<Flag code={iface.serverCountry} size={12} />
|
||||
{iface.serverName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* port */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">Порт</p>
|
||||
<p className="font-mono text-sm">{iface.listenPort}</p>
|
||||
</div>
|
||||
|
||||
{/* MTU */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">MTU</p>
|
||||
<p className="font-mono text-sm">{iface.mtu}</p>
|
||||
</div>
|
||||
|
||||
{/* peers */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">Пиров</p>
|
||||
<p className="font-mono text-sm">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">{onlinePeers}</span>
|
||||
<span className="text-muted-foreground">/{iface.peers.length}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* status badge */}
|
||||
<span className={cn(
|
||||
"text-[11px] font-mono px-2 py-0.5 rounded border",
|
||||
iface.status === "up"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||
)}>
|
||||
{iface.status === "up" ? "UP" : "DOWN"}
|
||||
</span>
|
||||
|
||||
{/* menu */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={(e) => e.stopPropagation()}>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
} />
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onExport() }}>
|
||||
<CodeXmlIcon className="size-4" />Экспорт .rsc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
|
||||
<DropdownMenuItem><PlusIcon className="size-4" />Добавить пира</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem><PowerIcon className="size-4" />{iface.enabled ? "Отключить" : "Включить"}</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* expanded peers */}
|
||||
{expanded && iface.peers.length > 0 && (
|
||||
<div>
|
||||
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-4 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground border-t border-border/50">
|
||||
<span>Public Key</span>
|
||||
<span>Allowed IPs</span>
|
||||
<span>Последнее рукопожатие</span>
|
||||
<span>RX / TX</span>
|
||||
<span>Endpoint</span>
|
||||
</div>
|
||||
{iface.peers.map((p) => <PeerRow key={p.publicKey} peer={p} />)}
|
||||
</div>
|
||||
)}
|
||||
{expanded && iface.peers.length === 0 && (
|
||||
<div className="px-4 py-4 text-xs text-muted-foreground text-center border-t border-border/50">
|
||||
Нет пиров
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ExportSheet({ open, iface, onClose }: {
|
||||
@@ -311,7 +136,6 @@ export default function WireGuardPage() {
|
||||
const allIfaces = useMemo(() => collectInterfaces(), [])
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -328,14 +152,6 @@ export default function WireGuardPage() {
|
||||
const onlinePeers = allIfaces.reduce((s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length, 0)
|
||||
const upIfaces = allIfaces.filter((i) => i.status === "up").length
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
@@ -385,50 +201,18 @@ export default function WireGuardPage() {
|
||||
</div>
|
||||
|
||||
{/* Search + table */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[260px]">
|
||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||
placeholder="Поиск по имени, серверу, IP…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по имени, серверу, IP…"
|
||||
countLabel={`${filtered.length} интерфейсов`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} интерфейсов</span>
|
||||
</div>
|
||||
|
||||
{/* table header */}
|
||||
<div className="grid grid-cols-[20px_1fr_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20">
|
||||
<span />
|
||||
<span>Интерфейс / Сервер</span>
|
||||
<span>Порт</span>
|
||||
<span>MTU</span>
|
||||
<span>Пиры</span>
|
||||
<span>Статус</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<ShieldCheckIcon className="size-4" />}
|
||||
title="Нет WireGuard интерфейсов"
|
||||
description="Добавьте первый интерфейс или проверьте поиск"
|
||||
className="border-0 py-16"
|
||||
<WireguardDataGrid
|
||||
interfaces={filtered}
|
||||
onExport={setExportIface}
|
||||
/>
|
||||
) : (
|
||||
filtered.map((iface) => (
|
||||
<IfaceRow
|
||||
key={iface.id}
|
||||
iface={iface}
|
||||
expanded={expandedIds.has(iface.id)}
|
||||
onToggleExpand={() => toggleExpand(iface.id)}
|
||||
onExport={() => setExportIface(iface)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
</DataPageCard>
|
||||
|
||||
{/* RouterOS reference */}
|
||||
<Card>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Asn } from "@/lib/data"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { FilterIcon, NetworkIcon } from "lucide-react"
|
||||
|
||||
interface AsnsDataGridProps {
|
||||
asns: Asn[]
|
||||
isLoading?: boolean
|
||||
pagination?: boolean
|
||||
}
|
||||
|
||||
function AsnsDataGrid({ asns, isLoading, pagination = false }: AsnsDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<Asn>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "asn",
|
||||
accessorKey: "asn",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="ASN" className="ml-1" />,
|
||||
cell: ({ row }) => <span className="font-mono font-semibold">{row.original.asn}</span>,
|
||||
meta: {
|
||||
headerTitle: "ASN",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "org",
|
||||
accessorKey: "org",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Имя / организация" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium max-w-[min(28rem,50vw)] truncate block" title={row.original.org}>
|
||||
{row.original.org}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Имя / организация",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prefixes",
|
||||
accessorKey: "prefixes",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Префиксов" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums">{row.original.prefixes.toLocaleString("ru")}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Префиксов",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "filter",
|
||||
accessorKey: "filter",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Фильтр" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />
|
||||
{row.original.filter}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Фильтр",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
accessorKey: "updated",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Обновлён" />,
|
||||
cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.original.updated}</span>,
|
||||
meta: {
|
||||
headerTitle: "Обновлён",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={`text-xs font-medium ${row.original.enabled ? "text-emerald-600" : "text-muted-foreground"}`}
|
||||
>
|
||||
{row.original.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: asns,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
...(pagination ? { getPaginationRowModel: getPaginationRowModel() } : {}),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={asns.length}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
pagination={pagination}
|
||||
emptyMessage={
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title="Нет ASN"
|
||||
description="Добавьте автономные системы или импортируйте каталог"
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { AsnsDataGrid, type AsnsDataGridProps }
|
||||
@@ -10,12 +10,13 @@ import {
|
||||
import type { Backup } from "@/lib/data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DataGrid,
|
||||
DataGridColumnHeader,
|
||||
DataGridContainer,
|
||||
DataGridTable,
|
||||
} from "@/components/reui/data-grid"
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { DownloadIcon, HardDriveIcon, RefreshCwIcon, Trash2Icon } from "lucide-react"
|
||||
|
||||
@@ -37,31 +38,46 @@ function BackupsDataGrid({
|
||||
{
|
||||
id: "filename",
|
||||
accessorKey: "filename",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Файл" />,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Файл" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs font-medium">{row.original.filename}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Файл",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "server",
|
||||
accessorKey: "server",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Сервер" />,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">{row.original.server}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "size",
|
||||
accessorKey: "size",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Размер" />,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Размер" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.size}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Размер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
accessorKey: "kind",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => {
|
||||
const kind = row.original.kind
|
||||
return (
|
||||
@@ -77,32 +93,47 @@ function BackupsDataGrid({
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Тип",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "notes",
|
||||
accessorKey: "notes",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Заметки" />,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Заметки" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground max-w-[200px] truncate block">
|
||||
{row.original.notes || "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Заметки",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
accessorKey: "created",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Создан" />,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Создан" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">{row.original.created}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Создан",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => null,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<div className="flex items-center gap-1 justify-end opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -135,6 +166,10 @@ function BackupsDataGrid({
|
||||
},
|
||||
enableSorting: false,
|
||||
size: 120,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[onDelete, onDownload, onRestore],
|
||||
@@ -159,13 +194,7 @@ function BackupsDataGrid({
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGrid table={table} recordCount={backups.length} isLoading={false}>
|
||||
<DataGridContainer>
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
</DataGrid>
|
||||
)
|
||||
return <DataGridShell table={table} recordCount={backups.length} />
|
||||
}
|
||||
|
||||
export { BackupsDataGrid, type BackupsDataGridProps }
|
||||
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DataGrid,
|
||||
DataGridColumnHeader,
|
||||
DataGridContainer,
|
||||
DataGridTable,
|
||||
} from "@/components/reui/data-grid"
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { BgpSessionDetail } from "@/components/data-grids/bgp-session-detail"
|
||||
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
|
||||
@@ -109,7 +109,9 @@ function BgpSessionsDataGrid({ sessions }: BgpSessionsDataGridProps) {
|
||||
{
|
||||
id: "serverLabel",
|
||||
accessorKey: "serverLabel",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роутер" />,
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Роутер" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const expanded = row.getIsExpanded()
|
||||
return (
|
||||
@@ -125,19 +127,26 @@ function BgpSessionsDataGrid({ sessions }: BgpSessionsDataGridProps) {
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Роутер",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
expandedContent: (row: BgpSessionRow) => <BgpSessionDetail session={row} />,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "peerIp",
|
||||
accessorKey: "peerIp",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Peer IP" />,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Peer IP" />,
|
||||
cell: ({ row }) => <span className="font-mono">{row.original.peerIp}</span>,
|
||||
meta: {
|
||||
headerTitle: "Peer IP",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "remoteAs",
|
||||
accessorKey: "remoteAs",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Remote AS" />,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Remote AS" />,
|
||||
cell: ({ row }) => {
|
||||
const as = row.original.remoteAs
|
||||
return (
|
||||
@@ -149,43 +158,68 @@ function BgpSessionsDataGrid({ sessions }: BgpSessionsDataGridProps) {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Remote AS",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "description",
|
||||
accessorKey: "description",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Описание" />,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Описание" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground max-w-[180px] truncate block">
|
||||
{row.original.description}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Описание",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => <TypeBadge type={row.original.type} />,
|
||||
meta: {
|
||||
headerTitle: "Тип",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
accessorKey: "state",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => <StateBadge state={row.original.state} />,
|
||||
meta: {
|
||||
headerTitle: "Состояние",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
accessorKey: "uptime",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Uptime" />,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Uptime" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-muted-foreground">
|
||||
{row.original.uptime ?? "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Uptime",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prefixesRx",
|
||||
accessorKey: "prefixesRx",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Prefixes ↓" />,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Prefixes ↓" />,
|
||||
cell: ({ row }) => {
|
||||
const n = row.original.prefixesRx
|
||||
return n > 0 ? (
|
||||
@@ -196,11 +230,16 @@ function BgpSessionsDataGrid({ sessions }: BgpSessionsDataGridProps) {
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Prefixes ↓",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prefixesTx",
|
||||
accessorKey: "prefixesTx",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Prefixes ↑" />,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Prefixes ↑" />,
|
||||
cell: ({ row }) => {
|
||||
const n = row.original.prefixesTx
|
||||
return n > 0 ? (
|
||||
@@ -211,6 +250,11 @@ function BgpSessionsDataGrid({ sessions }: BgpSessionsDataGridProps) {
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Prefixes ↑",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
@@ -223,6 +267,7 @@ function BgpSessionsDataGrid({ sessions }: BgpSessionsDataGridProps) {
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
getRowCanExpand: () => true,
|
||||
})
|
||||
|
||||
if (sessions.length === 0) {
|
||||
@@ -237,16 +282,11 @@ function BgpSessionsDataGrid({ sessions }: BgpSessionsDataGridProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={sessions.length}
|
||||
isLoading={false}
|
||||
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
||||
>
|
||||
<DataGridContainer>
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
</DataGrid>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function daysLeftBar(days: number, total = 365): number {
|
||||
if (days <= 0) return 0
|
||||
return Math.min(100, Math.round((days / total) * 100))
|
||||
}
|
||||
|
||||
function CertificateExpandedDetail({ cert }: { cert: CertificateDto }) {
|
||||
const pct = daysLeftBar(cert.daysLeft)
|
||||
|
||||
return (
|
||||
<div className="px-10 pb-4 grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs border-t border-border/50 pt-3">
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Key size</p>
|
||||
<p className="font-mono font-medium">{cert.keySize} bit</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Действителен с</p>
|
||||
<p className="font-mono">{cert.validFrom}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">SAN / Alt Names</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{cert.sans.length > 0
|
||||
? cert.sans.map((s) => (
|
||||
<span key={s} className="font-mono bg-muted px-1.5 py-0.5 rounded">
|
||||
{s}
|
||||
</span>
|
||||
))
|
||||
: <span className="text-muted-foreground">—</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Trusted</p>
|
||||
<p className={cert.trusted ? "text-emerald-600 dark:text-emerald-400" : "text-red-500"}>
|
||||
{cert.trusted ? "Да (доверенный)" : "Нет (не доверенный)"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-2 sm:col-span-4">
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden max-w-xs">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
cert.daysLeft < 0 || cert.daysLeft <= 7
|
||||
? "bg-red-500"
|
||||
: cert.daysLeft <= 30
|
||||
? "bg-amber-500"
|
||||
: "bg-emerald-500",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { CertificateExpandedDetail, daysLeftBar }
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, type ReactNode } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { CertStatus, Server } from "@/lib/data"
|
||||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import {
|
||||
CertificateExpandedDetail,
|
||||
daysLeftBar,
|
||||
} from "@/components/data-grids/certificate-expanded-detail"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
BadgeCheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ServerIcon,
|
||||
ShieldAlertIcon,
|
||||
ShieldCheckIcon,
|
||||
ShieldOffIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
CertStatus,
|
||||
{ label: string; icon: ReactNode; badge: string; row: string }
|
||||
> = {
|
||||
valid: {
|
||||
label: "Действителен",
|
||||
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />,
|
||||
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
||||
row: "",
|
||||
},
|
||||
expired: {
|
||||
label: "Истёк",
|
||||
icon: <ShieldOffIcon className="size-4 text-red-500" />,
|
||||
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||
row: "bg-red-500/5",
|
||||
},
|
||||
revoked: {
|
||||
label: "Отозван",
|
||||
icon: <ShieldAlertIcon className="size-4 text-amber-500" />,
|
||||
badge: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||||
row: "bg-amber-500/5",
|
||||
},
|
||||
}
|
||||
|
||||
function daysLeftColor(days: number): string {
|
||||
if (days < 0) return "text-red-500"
|
||||
if (days <= 7) return "text-red-500"
|
||||
if (days <= 30) return "text-amber-500"
|
||||
return "text-emerald-600 dark:text-emerald-400"
|
||||
}
|
||||
|
||||
function CertDaysCell({ cert }: { cert: CertificateDto }) {
|
||||
const pct = daysLeftBar(cert.daysLeft)
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className={cn("font-mono font-medium", daysLeftColor(cert.daysLeft))}>
|
||||
{cert.daysLeft < 0 ? `Истёк ${-cert.daysLeft}д назад` : `${cert.daysLeft}д осталось`}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">{cert.validUntil}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
cert.daysLeft < 0 || cert.daysLeft <= 7
|
||||
? "bg-red-500"
|
||||
: cert.daysLeft <= 30
|
||||
? "bg-amber-500"
|
||||
: "bg-emerald-500",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface CertificatesDataGridProps {
|
||||
certificates: CertificateDto[]
|
||||
serverMap: Map<string, Server>
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
function CertificatesDataGrid({ certificates, serverMap, isLoading }: CertificatesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<CertificateDto>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Сертификат" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const cert = row.original
|
||||
const cfg = STATUS_CONFIG[cert.status]
|
||||
const expanded = row.getIsExpanded()
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
{expanded ? (
|
||||
<ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="shrink-0">{cfg.icon}</span>
|
||||
<span className="font-medium text-sm truncate" title={cert.name}>
|
||||
{cert.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono mt-0.5 truncate" title={cert.commonName}>
|
||||
{cert.commonName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Сертификат",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
expandedContent: (row: CertificateDto) => <CertificateExpandedDetail cert={row} />,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "server",
|
||||
accessorKey: "serverId",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => {
|
||||
const cert = row.original
|
||||
const server = serverMap.get(cert.serverId)
|
||||
return server ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
|
||||
<Flag code={server.country} size={12} />
|
||||
<span className="font-mono truncate">{server.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
|
||||
<ServerIcon className="size-3.5" />
|
||||
<span className="font-mono truncate">{cert.serverName ?? cert.serverId}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "issuedBy",
|
||||
accessorKey: "issuedBy",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Издатель" />,
|
||||
cell: ({ row }) => (
|
||||
<p className="text-xs text-muted-foreground truncate min-w-0" title={row.original.issuedBy}>
|
||||
{row.original.issuedBy}
|
||||
</p>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Издатель",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "daysLeft",
|
||||
accessorKey: "daysLeft",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Срок" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="min-w-0">
|
||||
<CertDaysCell cert={row.original} />
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Срок",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "usage",
|
||||
accessorKey: "usage",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Использование</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-wrap gap-1 min-w-0 overflow-hidden">
|
||||
{row.original.usage.map((u) => (
|
||||
<span
|
||||
key={u}
|
||||
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground border"
|
||||
>
|
||||
{u}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Использование",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => {
|
||||
const cfg = STATUS_CONFIG[row.original.status]
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap",
|
||||
cfg.badge,
|
||||
)}
|
||||
>
|
||||
{cfg.label}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[serverMap],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: certificates,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
getRowCanExpand: () => true,
|
||||
})
|
||||
|
||||
if (!isLoading && certificates.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ShieldCheckIcon className="size-4" />}
|
||||
title="Нет сертификатов"
|
||||
description="Выпустите или импортируйте сертификат"
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={certificates.length}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { CertificatesDataGrid, STATUS_CONFIG, type CertificatesDataGridProps }
|
||||
@@ -0,0 +1,272 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
ServerIcon,
|
||||
TagIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export type CommunityType = "standard" | "no-export" | "no-advertise" | "local-as" | "custom"
|
||||
|
||||
export interface CommunityRow {
|
||||
id: string
|
||||
value: string
|
||||
name: string
|
||||
description: string
|
||||
type: CommunityType
|
||||
filterIds: string[]
|
||||
serverCount: number
|
||||
prefixCount: number
|
||||
action: "permit" | "deny" | "local-pref" | "metric"
|
||||
actionValue?: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<CommunityType, string> = {
|
||||
standard: "Стандартный",
|
||||
"no-export": "No-export",
|
||||
"no-advertise": "No-advertise",
|
||||
"local-as": "Local-AS",
|
||||
custom: "Кастомный",
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<CommunityRow["action"], string> = {
|
||||
permit: "Permit",
|
||||
deny: "Deny",
|
||||
"local-pref": "Local-pref",
|
||||
metric: "MED/Metric",
|
||||
}
|
||||
|
||||
const ACTION_COLOR: Record<CommunityRow["action"], string> = {
|
||||
permit: "text-emerald-500",
|
||||
deny: "text-red-500",
|
||||
"local-pref": "text-blue-500",
|
||||
metric: "text-amber-500",
|
||||
}
|
||||
|
||||
interface CommunitiesDataGridProps {
|
||||
communities: CommunityRow[]
|
||||
selectedId?: string | null
|
||||
copiedValue?: string | null
|
||||
onSelect: (community: CommunityRow) => void
|
||||
onCopy: (value: string) => void
|
||||
}
|
||||
|
||||
function CommunitiesDataGrid({
|
||||
communities,
|
||||
selectedId,
|
||||
copiedValue,
|
||||
onSelect,
|
||||
onCopy,
|
||||
}: CommunitiesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<CommunityRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "value",
|
||||
accessorKey: "value",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Community" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
data-community-disabled={!c.enabled ? true : undefined}
|
||||
>
|
||||
<TagIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-xs font-medium bg-muted px-1.5 py-0.5 rounded">
|
||||
{c.value}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onCopy(c.value)
|
||||
}}
|
||||
className="text-muted-foreground/40 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
{copiedValue === c.value ? (
|
||||
<CheckIcon className="size-3" />
|
||||
) : (
|
||||
<CopyIcon className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Community",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Имя / описание" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-xs">{row.original.name}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">{row.original.description}</p>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Имя / описание",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{TYPE_LABELS[row.original.type]}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Тип",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
accessorKey: "action",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Действие" />,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
return (
|
||||
<span className={cn("text-xs font-medium", ACTION_COLOR[c.action])}>
|
||||
{ACTION_LABELS[c.action]}
|
||||
{c.actionValue !== undefined ? ` ${c.actionValue}` : ""}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Действие",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prefixCount",
|
||||
accessorKey: "prefixCount",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Маршрутов" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs tabular-nums text-right block">
|
||||
{row.original.prefixCount.toLocaleString("ru-RU")}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Маршрутов",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "serverCount",
|
||||
accessorKey: "serverCount",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Серверов" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<ServerIcon className="size-3 text-muted-foreground" />
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{row.original.serverCount.toLocaleString("ru-RU")}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Серверов",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "chevron",
|
||||
header: () => <span className="sr-only">Детали</span>,
|
||||
enableSorting: false,
|
||||
cell: () => <ChevronRightIcon className="size-4 text-muted-foreground/40" />,
|
||||
size: 40,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[copiedValue, onCopy],
|
||||
)
|
||||
|
||||
const rowSelection = useMemo(
|
||||
() => (selectedId ? { [selectedId]: true } : {}),
|
||||
[selectedId],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: communities,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
enableRowSelection: true,
|
||||
state: { rowSelection },
|
||||
})
|
||||
|
||||
if (communities.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<TagIcon className="size-4" />}
|
||||
title="Ничего не найдено"
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={communities.length}
|
||||
onRowClick={(row) => onSelect(row)}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn(
|
||||
"group/row",
|
||||
"data-[state=selected]:bg-primary/5",
|
||||
"[&:has([data-community-disabled=true])]:opacity-50",
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
CommunitiesDataGrid,
|
||||
type CommunitiesDataGridProps,
|
||||
TYPE_LABELS,
|
||||
ACTION_LABELS,
|
||||
ACTION_COLOR,
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, type ReactNode } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { InboxIcon } from "lucide-react"
|
||||
|
||||
export interface CompactDataGridColumn<T> {
|
||||
id: string
|
||||
header: string
|
||||
accessorKey?: keyof T & string
|
||||
cell?: (row: T) => ReactNode
|
||||
enableSorting?: boolean
|
||||
headerClassName?: string
|
||||
cellClassName?: string
|
||||
}
|
||||
|
||||
interface CompactDataGridProps<T extends { id: string }> {
|
||||
data: T[]
|
||||
columns: CompactDataGridColumn<T>[]
|
||||
isLoading?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
onRowClick?: (row: T) => void
|
||||
getExpandedContent?: (row: T) => ReactNode
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
function CompactDataGrid<T extends { id: string }>({
|
||||
data,
|
||||
columns,
|
||||
isLoading,
|
||||
emptyTitle = "Нет записей",
|
||||
emptyDescription,
|
||||
onRowClick,
|
||||
getExpandedContent,
|
||||
compact = false,
|
||||
}: CompactDataGridProps<T>) {
|
||||
const pad = compact ? "py-2" : DATA_GRID_CELL_PAD
|
||||
const padFirst = compact ? "pl-4 py-2" : DATA_GRID_CELL_PAD_FIRST
|
||||
const padLast = compact ? "pr-4 py-2" : DATA_GRID_CELL_PAD_LAST
|
||||
|
||||
const columnDefs = useMemo<ColumnDef<T>[]>(
|
||||
() =>
|
||||
columns.map((col, index) => ({
|
||||
id: col.id,
|
||||
accessorKey: col.accessorKey ?? col.id,
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title={col.header} className={index === 0 ? "ml-1" : undefined} />
|
||||
),
|
||||
cell: ({ row }) => (col.cell ? col.cell(row.original) : String(row.getValue(col.id) ?? "—")),
|
||||
enableSorting: col.enableSorting !== false,
|
||||
meta: {
|
||||
headerTitle: col.header,
|
||||
headerClassName:
|
||||
col.headerClassName ??
|
||||
(index === 0 ? padFirst : index === columns.length - 1 ? padLast : pad),
|
||||
cellClassName:
|
||||
col.cellClassName ??
|
||||
(index === 0 ? padFirst : index === columns.length - 1 ? padLast : pad),
|
||||
...(getExpandedContent && index === 0
|
||||
? { expandedContent: getExpandedContent }
|
||||
: {}),
|
||||
},
|
||||
})),
|
||||
[columns, getExpandedContent, pad, padFirst, padLast],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: columnDefs,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
...(getExpandedContent ? { getExpandedRowModel: getExpandedRowModel() } : {}),
|
||||
getRowId: (row) => row.id,
|
||||
...(getExpandedContent ? { getRowCanExpand: () => true } : {}),
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
onRowClick={
|
||||
onRowClick ??
|
||||
(getExpandedContent
|
||||
? (row) => table.getRow(row.id).toggleExpanded()
|
||||
: undefined)
|
||||
}
|
||||
emptyMessage={
|
||||
<EmptyState
|
||||
icon={<InboxIcon className="size-4" />}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { CompactDataGrid, type CompactDataGridProps }
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import type { PingProbe, Server, ServerType } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { Sparkline } from "@/components/sparkline"
|
||||
import { CompactDataGrid, type CompactDataGridColumn } from "@/components/data-grids/compact-data-grid"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FilterIcon } from "lucide-react"
|
||||
|
||||
function formatLossPct(loss: number): string {
|
||||
if (!Number.isFinite(loss)) return "—"
|
||||
return Number.isInteger(loss) ? `${loss}%` : `${loss.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function TypeChip({ type }: { type: ServerType }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
|
||||
type === "home-router"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: type === "jump-host"
|
||||
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
|
||||
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
)}
|
||||
>
|
||||
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server[] }) {
|
||||
const srv = catalog.find((s) => s.id === probe.srcServerId)
|
||||
const iface = (probe.srcInterface ?? "").trim() || "auto"
|
||||
|
||||
if (!srv) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
|
||||
<span className="mt-1 shrink-0 inline-flex">
|
||||
<StatusDot status="offline" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-medium text-muted-foreground truncate">
|
||||
Сервер <span className="font-mono tabular-nums">{probe.srcServerId}</span>
|
||||
</p>
|
||||
<p className="text-[11px] font-mono text-muted-foreground truncate mt-0.5" title={iface}>
|
||||
{iface}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
|
||||
<span className="mt-1 shrink-0 inline-flex">
|
||||
<StatusDot status={srv.status} pulse={srv.status === "online"} />
|
||||
</span>
|
||||
<div className="flex gap-2 min-w-0 flex-1">
|
||||
<Flag code={srv.country} size={16} className="shrink-0 mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[13px] font-medium leading-tight truncate">{srv.name}</span>
|
||||
<TypeChip type={srv.type} />
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate" title={`Интерфейс: ${iface}`}>
|
||||
<span className="font-mono tabular-nums">{iface}</span>
|
||||
{srv.site && srv.site !== "—" && (
|
||||
<span className="text-muted-foreground/90"> · {srv.site}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DashboardActiveProbesDataGridProps {
|
||||
probes: PingProbe[]
|
||||
catalog: Server[]
|
||||
isLoading?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}
|
||||
|
||||
function DashboardActiveProbesDataGrid({
|
||||
probes,
|
||||
catalog,
|
||||
isLoading,
|
||||
emptyTitle = "Нет активных проб",
|
||||
emptyDescription,
|
||||
}: DashboardActiveProbesDataGridProps) {
|
||||
const columns = useMemo<CompactDataGridColumn<PingProbe>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "source",
|
||||
header: "Источник",
|
||||
enableSorting: false,
|
||||
cell: (p) => <ProbeSourceCell probe={p} catalog={catalog} />,
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
header: "Проба",
|
||||
accessorKey: "name",
|
||||
cell: (p) => <span className="font-medium">{p.name}</span>,
|
||||
},
|
||||
{
|
||||
id: "target",
|
||||
header: "Цель",
|
||||
accessorKey: "target",
|
||||
cell: (p) => <span className="font-mono text-xs text-muted-foreground">{p.target}</span>,
|
||||
},
|
||||
{
|
||||
id: "filter",
|
||||
header: "Фильтр",
|
||||
accessorKey: "filter",
|
||||
enableSorting: false,
|
||||
cell: (p) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs border border-border rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />
|
||||
{p.filter}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "rtt",
|
||||
header: "RTT",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "font-mono text-right",
|
||||
cell: (p) => (p.rtt == null ? "—" : `${p.rtt} мс`),
|
||||
},
|
||||
{
|
||||
id: "loss",
|
||||
header: "Потери",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "font-mono text-right",
|
||||
cell: (p) => (
|
||||
<span
|
||||
className={cn(
|
||||
p.loss > 5 ? "text-red-500" : p.loss > 0 ? "text-amber-500" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{formatLossPct(p.loss)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "series",
|
||||
header: "60с",
|
||||
enableSorting: false,
|
||||
headerClassName: "w-36",
|
||||
cell: (p) => {
|
||||
const sparkColor =
|
||||
p.status === "down"
|
||||
? "hsl(0 84% 60%)"
|
||||
: p.status === "warn"
|
||||
? "hsl(32 94% 44%)"
|
||||
: "hsl(142 76% 36%)"
|
||||
return <Sparkline data={p.series} width={120} height={24} color={sparkColor} />
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Статус",
|
||||
enableSorting: false,
|
||||
cell: (p) => (
|
||||
<StatusBadge
|
||||
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[catalog],
|
||||
)
|
||||
|
||||
return (
|
||||
<CompactDataGrid
|
||||
data={probes}
|
||||
columns={columns}
|
||||
compact
|
||||
isLoading={isLoading}
|
||||
emptyTitle={emptyTitle}
|
||||
emptyDescription={emptyDescription}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { DashboardActiveProbesDataGrid, type DashboardActiveProbesDataGridProps }
|
||||
@@ -0,0 +1,219 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { SchedulerJobStatusDto } from "@/lib/scheduler-settings"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { RefreshCwIcon } from "lucide-react"
|
||||
|
||||
export interface SchedulerJobGridRow {
|
||||
id: string
|
||||
jobKey: string
|
||||
label: string
|
||||
description: string
|
||||
fixedSchedule: boolean
|
||||
enabled: boolean
|
||||
intervalValue: string
|
||||
intervalReadOnly: boolean
|
||||
intervalDisabled: boolean
|
||||
defaultInterval: number
|
||||
job?: SchedulerJobStatusDto
|
||||
onEnabledChange?: (enabled: boolean) => void
|
||||
onIntervalChange: (value: string) => void
|
||||
onRunNow: () => void
|
||||
runNowLoading: boolean
|
||||
saveBusy: boolean
|
||||
}
|
||||
|
||||
interface DataCollectionSchedulerDataGridProps {
|
||||
rows: SchedulerJobGridRow[]
|
||||
}
|
||||
|
||||
function DataCollectionSchedulerDataGrid({ rows }: DataCollectionSchedulerDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<SchedulerJobGridRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "task",
|
||||
accessorKey: "label",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Задача</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="align-top">
|
||||
<span className="font-medium text-sm">{row.original.label}</span>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 leading-snug">
|
||||
{row.original.description}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground font-mono mt-1">{row.original.jobKey}</p>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD_FIRST, "align-top"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground text-center block">Вкл</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const { fixedSchedule, enabled, saveBusy, onEnabledChange } = row.original
|
||||
return (
|
||||
<div className="text-center align-top">
|
||||
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
|
||||
<FormToggle
|
||||
checked={enabled}
|
||||
disabled={fixedSchedule || saveBusy}
|
||||
onChange={(v) => {
|
||||
if (fixedSchedule || saveBusy) return
|
||||
onEnabledChange?.(v)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 56,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: cn(DATA_GRID_CELL_PAD, "align-top") },
|
||||
},
|
||||
{
|
||||
id: "interval",
|
||||
accessorKey: "intervalValue",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Интервал (с)</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<div className="w-28 align-top">
|
||||
<Input
|
||||
value={r.intervalValue}
|
||||
onChange={(e) => r.onIntervalChange(e.target.value)}
|
||||
className="h-8 text-sm tabular-nums"
|
||||
inputMode="numeric"
|
||||
readOnly={r.intervalReadOnly}
|
||||
disabled={r.intervalDisabled}
|
||||
placeholder={String(r.defaultInterval)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: cn(DATA_GRID_CELL_PAD, "align-top") },
|
||||
},
|
||||
{
|
||||
id: "lastRun",
|
||||
accessorFn: (row) => row.job?.lastFinishedAt ?? "",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Последний прогон</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const j = row.original.job
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground align-top">
|
||||
{j?.lastFinishedAt ? new Date(j.lastFinishedAt).toLocaleString("ru-RU") : "—"}
|
||||
{j?.lastDurationMs != null && (
|
||||
<span className="block text-[11px]">{j.lastDurationMs} мс</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: cn(DATA_GRID_CELL_PAD, "align-top") },
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorFn: (row) => row.job?.lastStatus ?? "",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Статус</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const j = row.original.job
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5 align-top">
|
||||
{j?.running ? (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
выполняется
|
||||
</Badge>
|
||||
) : null}
|
||||
{j?.lastStatus ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
j.lastStatus === "ok" && "border-emerald-500/40 text-emerald-700 dark:text-emerald-400",
|
||||
j.lastStatus === "error" && "border-destructive/50 text-destructive",
|
||||
)}
|
||||
>
|
||||
{j.lastStatus}
|
||||
</Badge>
|
||||
) : null}
|
||||
{j?.lastError ? (
|
||||
<span className="text-[10px] text-destructive max-w-[200px] truncate block" title={j.lastError}>
|
||||
{j.lastError}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: cn(DATA_GRID_CELL_PAD, "align-top") },
|
||||
},
|
||||
{
|
||||
id: "runNow",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground text-right block">Сейчас</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<div className="text-right align-top">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={r.job?.running || r.runNowLoading}
|
||||
onClick={r.onRunNow}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-3.5", r.runNowLoading && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "align-top"),
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rows.length}
|
||||
tableClassNames={{ bodyRow: "group/row hover:bg-muted/40 text-sm" }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataCollectionSchedulerDataGrid, type DataCollectionSchedulerDataGridProps }
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Domain } from "@/lib/data"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { FilterIcon, GlobeIcon } from "lucide-react"
|
||||
|
||||
interface DomainsDataGridProps {
|
||||
domains: Domain[]
|
||||
isLoading?: boolean
|
||||
pagination?: boolean
|
||||
}
|
||||
|
||||
function DomainsDataGrid({ domains, isLoading, pagination = false }: DomainsDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<Domain>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "domain",
|
||||
accessorKey: "domain",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Домен" className="ml-1" />,
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.domain}</span>,
|
||||
meta: {
|
||||
headerTitle: "Домен",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "resolvedIp",
|
||||
accessorKey: "resolvedIp",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Resolved IP" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.resolvedIp}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Resolved IP",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "asn",
|
||||
accessorKey: "asn",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="ASN" />,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.asn}</span>,
|
||||
meta: {
|
||||
headerTitle: "ASN",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "purpose",
|
||||
accessorKey: "purpose",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs border border-border rounded px-2 py-0.5">{row.original.purpose}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Назначение",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "filter",
|
||||
accessorKey: "filter",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Фильтр" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />
|
||||
{row.original.filter}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Фильтр",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
accessorKey: "updated",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Обновлён" />,
|
||||
cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.original.updated}</span>,
|
||||
meta: {
|
||||
headerTitle: "Обновлён",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={`text-xs font-medium ${row.original.enabled ? "text-emerald-600" : "text-muted-foreground"}`}
|
||||
>
|
||||
{row.original.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: domains,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
...(pagination ? { getPaginationRowModel: getPaginationRowModel() } : {}),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={domains.length}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
pagination={pagination}
|
||||
emptyMessage={
|
||||
<EmptyState
|
||||
icon={<GlobeIcon className="size-4" />}
|
||||
title="Нет доменов"
|
||||
description="Добавьте домены или импортируйте каталог"
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { DomainsDataGrid, type DomainsDataGridProps }
|
||||
@@ -0,0 +1,457 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { FilterRule, GreTunnel, Server } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
AlertTriangleIcon,
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
CircleDashedIcon,
|
||||
PencilIcon,
|
||||
RouteIcon,
|
||||
StarIcon,
|
||||
TrashIcon,
|
||||
XCircleIcon,
|
||||
FilterIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export type FilterRouterSyncStatus = "synced" | "drift" | "missing"
|
||||
|
||||
export interface RecursiveRouteLite {
|
||||
id: string
|
||||
dstAddress: string
|
||||
gateway: string
|
||||
distance: number
|
||||
routingTable: string
|
||||
comment: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
function RouterSyncMarker({
|
||||
status,
|
||||
}: {
|
||||
status: FilterRouterSyncStatus | null | "skip"
|
||||
}) {
|
||||
if (status === "skip") {
|
||||
return <span className="size-3.5 shrink-0 block" aria-hidden />
|
||||
}
|
||||
const icon =
|
||||
status === "synced"
|
||||
? <CheckCircle2Icon className="size-3.5 text-emerald-600 dark:text-emerald-500 shrink-0" />
|
||||
: status === "drift"
|
||||
? <AlertTriangleIcon className="size-3.5 text-amber-500 shrink-0" />
|
||||
: status === "missing"
|
||||
? <XCircleIcon className="size-3.5 text-destructive shrink-0" />
|
||||
: <CircleDashedIcon className="size-3.5 text-muted-foreground/35 shrink-0" />
|
||||
const title =
|
||||
status === "synced"
|
||||
? "Совпадает с цепочкой bgp-in на MikroTik"
|
||||
: status === "drift"
|
||||
? "В БД и на роутере разное действие (gateway, blackhole или out-interface)"
|
||||
: status === "missing"
|
||||
? "Эта community не найдена в правиле bgp-in на роутере"
|
||||
: "Не проверено — нажмите «Сверить с роутером»"
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="inline-flex cursor-default border-0 bg-transparent p-0">
|
||||
{icon}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{title}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function innerIpToGateway(ip: string) {
|
||||
return ip.split("/")[0]
|
||||
}
|
||||
|
||||
function gatewayFromRecursiveDst(dstAddress: string) {
|
||||
return innerIpToGateway((dstAddress ?? "").trim())
|
||||
}
|
||||
|
||||
function pickRecursiveRouteByGatewayHop(routes: RecursiveRouteLite[], hopIp: string) {
|
||||
const hop = hopIp.trim()
|
||||
if (!hop) return undefined
|
||||
const candidates = routes.filter(
|
||||
(r) => !r.disabled && gatewayFromRecursiveDst(r.dstAddress) === hop,
|
||||
)
|
||||
if (candidates.length === 0) return undefined
|
||||
return candidates.reduce((a, b) => (a.distance <= b.distance ? a : b))
|
||||
}
|
||||
|
||||
function isRecursiveGatewayRef(ref: string) {
|
||||
return ref.startsWith("rec:")
|
||||
}
|
||||
|
||||
function FilterGatewayCell({
|
||||
rule,
|
||||
tunnelsList,
|
||||
serversList,
|
||||
recursiveRoutes,
|
||||
}: {
|
||||
rule: FilterRule
|
||||
tunnelsList: GreTunnel[]
|
||||
serversList: Server[]
|
||||
recursiveRoutes: RecursiveRouteLite[]
|
||||
}) {
|
||||
const isBlackhole = rule.action === "blackhole"
|
||||
const isRecRef = !isBlackhole && isRecursiveGatewayRef(rule.gatewayTunnelId)
|
||||
const recRowByRef = isRecRef
|
||||
? recursiveRoutes.find((r) => r.id === rule.gatewayTunnelId.slice(4))
|
||||
: undefined
|
||||
const recRowByHop =
|
||||
!isBlackhole && !(rule.gatewayTunnelId ?? "").trim() && rule.gateway.trim()
|
||||
? pickRecursiveRouteByGatewayHop(recursiveRoutes, rule.gateway)
|
||||
: undefined
|
||||
const recRow = recRowByRef ?? recRowByHop
|
||||
const treatAsRecursive = !isBlackhole && (isRecRef || !!recRowByHop)
|
||||
const tunnel =
|
||||
!isBlackhole && !treatAsRecursive
|
||||
? tunnelsList.find((t) => t.id === rule.gatewayTunnelId)
|
||||
: undefined
|
||||
const remoteSrv = tunnel ? serversList.find((s) => s.host === tunnel.remoteAddress) : undefined
|
||||
|
||||
if (isBlackhole) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-1.5 rounded-full shrink-0 bg-red-500 animate-pulse" />
|
||||
<span className="font-mono text-xs font-medium text-red-600 dark:text-red-400">type=blackhole</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full shrink-0",
|
||||
treatAsRecursive
|
||||
? "bg-sky-500"
|
||||
: tunnel?.status === "up"
|
||||
? "bg-[var(--status-online)]"
|
||||
: tunnel?.status === "degraded"
|
||||
? "bg-[var(--status-degraded)]"
|
||||
: "bg-[var(--status-offline)]",
|
||||
)}
|
||||
/>
|
||||
{treatAsRecursive ? (
|
||||
<>
|
||||
<RouteIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-xs font-medium">
|
||||
{recRow ? gatewayFromRecursiveDst(recRow.dstAddress) : rule.gateway}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-muted-foreground border border-border rounded px-1 uppercase tracking-wide">
|
||||
recursive
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{remoteSrv && <Flag code={remoteSrv.country} />}
|
||||
<span className="font-mono text-xs font-medium">{rule.gateway}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{treatAsRecursive ? (
|
||||
recRow ? (
|
||||
<p className="text-[11px] text-muted-foreground truncate pl-3">{recRow.dstAddress}</p>
|
||||
) : isRecRef ? (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400 truncate pl-3">
|
||||
рекурсивный маршрут (нет строки в списке)
|
||||
</p>
|
||||
) : null
|
||||
) : tunnel ? (
|
||||
<p className="text-[11px] text-muted-foreground truncate pl-3">{tunnel.name}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterRowActions({
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const [confirmDel, setConfirmDel] = useState(false)
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 justify-end opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="size-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onEdit}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"size-7 p-0 transition-colors",
|
||||
confirmDel
|
||||
? "text-destructive bg-destructive/10 hover:bg-destructive/20"
|
||||
: "text-muted-foreground hover:text-destructive",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!confirmDel) setConfirmDel(true)
|
||||
else onDelete()
|
||||
}}
|
||||
onBlur={() => setConfirmDel(false)}
|
||||
>
|
||||
{confirmDel ? <AlertCircleIcon className="size-3.5" /> : <TrashIcon className="size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FiltersDataGridProps {
|
||||
rules: FilterRule[]
|
||||
tunnelsList: GreTunnel[]
|
||||
serversList: Server[]
|
||||
communityNameMap: Record<string, string>
|
||||
recursiveRoutes: RecursiveRouteLite[]
|
||||
routerSyncByCommunity?: Record<string, FilterRouterSyncStatus> | null
|
||||
isLive?: boolean
|
||||
enableSorting?: boolean
|
||||
onEdit: (rule: FilterRule) => void
|
||||
onDelete: (id: string) => void
|
||||
onMoveUp: (id: string) => void
|
||||
onMoveDown: (id: string) => void
|
||||
}
|
||||
|
||||
function FiltersDataGrid({
|
||||
rules,
|
||||
tunnelsList,
|
||||
serversList,
|
||||
communityNameMap,
|
||||
recursiveRoutes,
|
||||
routerSyncByCommunity,
|
||||
isLive,
|
||||
enableSorting = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
}: FiltersDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<FilterRule>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "priority",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">#</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row, table }) => {
|
||||
const isLast = row.index === table.getRowModel().rows.length - 1
|
||||
return (
|
||||
<div className="flex items-center justify-center text-[11px] font-mono text-muted-foreground/40">
|
||||
{isLast ? (
|
||||
<StarIcon className="size-3 text-amber-400 fill-amber-400" aria-label="Наивысший приоритет" />
|
||||
) : (
|
||||
<span>{row.index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 28,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||
},
|
||||
{
|
||||
id: "reorder",
|
||||
header: () => <span className="sr-only">Порядок</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row, table }) => {
|
||||
const isLast = row.index === table.getRowModel().rows.length - 1
|
||||
return (
|
||||
<div className="flex flex-col gap-px opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMoveUp(row.original.id)}
|
||||
disabled={row.index === 0}
|
||||
className="text-muted-foreground/50 hover:text-foreground disabled:opacity-20"
|
||||
>
|
||||
<ChevronUpIcon className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMoveDown(row.original.id)}
|
||||
disabled={isLast}
|
||||
className="text-muted-foreground/50 hover:text-foreground disabled:opacity-20"
|
||||
>
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 28,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "routerSync",
|
||||
header: () => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="cursor-help font-mono text-xs text-muted-foreground border-0 bg-transparent p-0">
|
||||
MT
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
Совпадение с MikroTik (bgp-in)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<RouterSyncMarker
|
||||
status={
|
||||
!isLive
|
||||
? "skip"
|
||||
: !routerSyncByCommunity
|
||||
? null
|
||||
: routerSyncByCommunity[row.original.community.trim()] ?? null
|
||||
}
|
||||
/>
|
||||
),
|
||||
size: 32,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "community",
|
||||
accessorKey: "community",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Community" />,
|
||||
enableSorting,
|
||||
cell: ({ row }) => {
|
||||
const rule = row.original
|
||||
const isBlackhole = rule.action === "blackhole"
|
||||
const communityName = communityNameMap[rule.community] ?? rule.communityName
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border font-mono",
|
||||
isBlackhole
|
||||
? "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/25"
|
||||
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
)}
|
||||
>
|
||||
{rule.community}
|
||||
</span>
|
||||
{isBlackhole && (
|
||||
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold border bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20 uppercase">
|
||||
⊘ blackhole
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{communityName && (
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">{communityName}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "Community", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "gateway",
|
||||
accessorKey: "gateway",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Gateway" />,
|
||||
enableSorting,
|
||||
cell: ({ row }) => (
|
||||
<FilterGatewayCell
|
||||
rule={row.original}
|
||||
tunnelsList={tunnelsList}
|
||||
serversList={serversList}
|
||||
recursiveRoutes={recursiveRoutes}
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: "Gateway", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "description",
|
||||
accessorKey: "description",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Описание" />,
|
||||
enableSorting,
|
||||
cell: ({ row }) => (
|
||||
<p className="text-xs text-muted-foreground truncate">{row.original.description || "—"}</p>
|
||||
),
|
||||
meta: { headerTitle: "Описание", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<FilterRowActions
|
||||
onEdit={() => onEdit(row.original)}
|
||||
onDelete={() => onDelete(row.original.id)}
|
||||
/>
|
||||
),
|
||||
size: 64,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[
|
||||
communityNameMap,
|
||||
enableSorting,
|
||||
isLive,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onMoveDown,
|
||||
onMoveUp,
|
||||
recursiveRoutes,
|
||||
routerSyncByCommunity,
|
||||
serversList,
|
||||
tunnelsList,
|
||||
],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: rules,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (rules.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<FilterIcon className="size-4" />}
|
||||
title="Ничего не найдено"
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataGridShell table={table} recordCount={rules.length} />
|
||||
<div className="px-5 py-2 text-[11px] text-muted-foreground/40 flex items-center gap-1.5 border-t">
|
||||
<StarIcon className="size-3 text-amber-400 fill-amber-400 shrink-0" />
|
||||
Последнее правило имеет наивысший приоритет в RouterOS
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export { FiltersDataGrid, RouterSyncMarker, type FiltersDataGridProps }
|
||||
@@ -0,0 +1,311 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { FirewallRule } from "@/lib/data"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
CopyIcon,
|
||||
MoreHorizontalIcon,
|
||||
PencilIcon,
|
||||
PowerIcon,
|
||||
ShieldOffIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
const ACTION_STYLES: Record<string, string> = {
|
||||
accept: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
||||
drop: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||
reject: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||
masquerade: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
|
||||
"mark-routing": "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||||
"mark-conn": "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||||
"fasttrack-connection": "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
|
||||
"dst-nat": "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
"src-nat": "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
"add-src-to-address-list": "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20",
|
||||
}
|
||||
|
||||
const CHAIN_STYLES: Record<string, string> = {
|
||||
forward: "bg-foreground/5 text-foreground/70",
|
||||
input: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
|
||||
output: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
srcnat: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||
dstnat: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||
prerouting: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
||||
postrouting: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
||||
"ip6-input": "bg-violet-500/10 text-violet-600 dark:text-violet-400",
|
||||
"ip6-forward": "bg-foreground/5 text-foreground/70",
|
||||
"ip6-output": "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
}
|
||||
|
||||
function fmtHits(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}М`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}к`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function ActionBadge({ action }: { action: string }) {
|
||||
const cls = ACTION_STYLES[action] ?? "bg-muted text-muted-foreground border-border"
|
||||
return (
|
||||
<span className={cn("text-[11px] font-mono font-medium px-2 py-0.5 rounded border whitespace-nowrap", cls)}>
|
||||
{action}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ChainBadge({ chain }: { chain: string }) {
|
||||
const cls = CHAIN_STYLES[chain] ?? "bg-muted text-muted-foreground"
|
||||
return (
|
||||
<span className={cn("text-[11px] font-mono px-2 py-0.5 rounded", cls)}>
|
||||
{chain}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface FirewallRulesDataGridProps {
|
||||
rules: FirewallRule[]
|
||||
onToggle: (id: string) => void
|
||||
onEdit: (rule: FirewallRule) => void
|
||||
}
|
||||
|
||||
function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGridProps) {
|
||||
const indexedRules = useMemo(
|
||||
() => rules.map((rule, index) => ({ ...rule, _index: index + 1 })),
|
||||
[rules],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<FirewallRule & { _index: number }>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "index",
|
||||
accessorKey: "_index",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">#</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className="font-mono text-xs text-muted-foreground"
|
||||
data-rule-disabled={!row.original.enabled ? true : undefined}
|
||||
>
|
||||
{row.original._index}
|
||||
</span>
|
||||
),
|
||||
size: 48,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "chain",
|
||||
accessorKey: "chain",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Цепочка" />,
|
||||
cell: ({ row }) => <ChainBadge chain={row.original.chain} />,
|
||||
meta: { headerTitle: "Цепочка", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
accessorKey: "action",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Действие" />,
|
||||
cell: ({ row }) => <ActionBadge action={row.original.action} />,
|
||||
meta: { headerTitle: "Действие", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "src",
|
||||
accessorKey: "src",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Источник" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground max-w-[140px] truncate block">
|
||||
{row.original.src || "any"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: "Источник", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "dst",
|
||||
accessorKey: "dst",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground max-w-[140px] truncate block">
|
||||
{row.original.dst || "any"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: "Назначение", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "proto",
|
||||
accessorKey: "proto",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Протокол" />,
|
||||
cell: ({ row }) => <span className="text-xs font-mono">{row.original.proto}</span>,
|
||||
meta: { headerTitle: "Протокол", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "port",
|
||||
accessorKey: "port",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Порт" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.port || "—"}</span>
|
||||
),
|
||||
meta: { headerTitle: "Порт", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "iface",
|
||||
accessorKey: "iface",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.iface || "—"}</span>
|
||||
),
|
||||
meta: { headerTitle: "Интерфейс", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "hits",
|
||||
accessorKey: "hits",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Пакетов" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const hits = row.original.hits
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-mono tabular-nums text-right block",
|
||||
hits > 1_000_000
|
||||
? "text-emerald-600 dark:text-emerald-400 font-semibold"
|
||||
: hits > 10_000
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{fmtHits(hits)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Пакетов",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Вкл</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<FormToggle checked={row.original.enabled} onChange={() => onToggle(row.original.id)} />
|
||||
</div>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||
"opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100",
|
||||
"data-popup-open:opacity-100",
|
||||
)}
|
||||
aria-label={`Действия: ${r.chain} ${r.action}`}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(r)}>
|
||||
<PencilIcon className="size-4" />
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CopyIcon className="size-4" />
|
||||
Дублировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onToggle(r.id)}>
|
||||
<PowerIcon className="size-4" />
|
||||
{r.enabled ? "Отключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<Trash2Icon className="size-4" />
|
||||
Удалить правило
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 56,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[onEdit, onToggle],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: indexedRules,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (rules.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ShieldOffIcon className="size-4" />}
|
||||
title="Правила не найдены"
|
||||
description="Попробуйте изменить фильтр или добавьте новое правило"
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rules.length}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row", "[&:has([data-rule-disabled=true])]:opacity-40"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { FirewallRulesDataGrid, type FirewallRulesDataGridProps, ActionBadge, ChainBadge }
|
||||
@@ -0,0 +1,216 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { ActionBadge, ChainBadge } from "@/components/data-grids/firewall-rules-data-grid"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PowerIcon, Trash2Icon } from "lucide-react"
|
||||
|
||||
function ScenarioCell({ enabled, children }: { enabled: boolean; children: React.ReactNode }) {
|
||||
return <div className={cn(!enabled && "opacity-40")}>{children}</div>
|
||||
}
|
||||
|
||||
export interface ScenarioRuleRow {
|
||||
id: string
|
||||
chain: string
|
||||
action: string
|
||||
proto: string
|
||||
src: string
|
||||
dst: string
|
||||
port: string
|
||||
iface: string
|
||||
comment: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface FirewallScenarioRulesDataGridProps {
|
||||
rules: ScenarioRuleRow[]
|
||||
onToggleEnabled: (id: string) => void
|
||||
onMoveUp: (id: string) => void
|
||||
onMoveDown: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
function FirewallScenarioRulesDataGrid({
|
||||
rules,
|
||||
onToggleEnabled,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onRemove,
|
||||
}: FirewallScenarioRulesDataGridProps) {
|
||||
const indexedRules = useMemo(
|
||||
() => rules.map((rule, index) => ({ ...rule, _index: index + 1 })),
|
||||
[rules],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<ScenarioRuleRow & { _index: number }>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "index",
|
||||
accessorKey: "_index",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">#</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<ScenarioCell enabled={row.original.enabled}>
|
||||
<span className="font-mono text-xs text-muted-foreground tabular-nums">{row.original._index}</span>
|
||||
</ScenarioCell>
|
||||
),
|
||||
size: 40,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "chain",
|
||||
accessorKey: "chain",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Цепочка</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<ScenarioCell enabled={row.original.enabled}>
|
||||
<ChainBadge chain={row.original.chain} />
|
||||
</ScenarioCell>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
accessorKey: "action",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Действие</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <ActionBadge action={row.original.action} />,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "src",
|
||||
accessorKey: "src",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Src</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground max-w-[90px] truncate block">
|
||||
{row.original.src || "any"}
|
||||
</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">Dst</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground max-w-[90px] truncate block">
|
||||
{row.original.dst || "any"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "port",
|
||||
accessorKey: "port",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Порт</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.port || "—"}</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "iface",
|
||||
accessorKey: "iface",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Iface</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.iface || "—"}</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "comment",
|
||||
accessorKey: "comment",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Комментарий</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground/70 max-w-[110px] truncate block">
|
||||
{row.original.comment || "—"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => null,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const index = row.original._index - 1
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 justify-end opacity-0 group-hover/row:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleEnabled(row.original.id)}
|
||||
title={row.original.enabled ? "Отключить" : "Включить"}
|
||||
className="p-0.5 text-muted-foreground/40 hover:text-foreground transition-colors"
|
||||
>
|
||||
<PowerIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMoveUp(row.original.id)}
|
||||
disabled={index === 0}
|
||||
className="p-0.5 text-muted-foreground/40 hover:text-foreground disabled:opacity-20 transition-colors"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMoveDown(row.original.id)}
|
||||
disabled={index === rules.length - 1}
|
||||
className="p-0.5 text-muted-foreground/40 hover:text-foreground disabled:opacity-20 transition-colors"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(row.original.id)}
|
||||
className="p-0.5 ml-0.5 text-muted-foreground/40 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[onMoveDown, onMoveUp, onRemove, onToggleEnabled, rules.length],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: indexedRules,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rules.length}
|
||||
tableClassNames={{
|
||||
bodyRow: cn("group/row text-xs", "hover:bg-muted/20 transition-colors"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { FirewallScenarioRulesDataGrid, type FirewallScenarioRulesDataGridProps }
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { GrePool } from "@/lib/data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { LayersIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react"
|
||||
|
||||
interface GrePoolsDataGridProps {
|
||||
pools: GrePool[]
|
||||
}
|
||||
|
||||
function GrePoolsDataGrid({ pools }: GrePoolsDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<GrePool>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Имя пула" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-[13px] font-medium">{row.original.name}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Имя пула",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cidr",
|
||||
accessorKey: "cidr",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Диапазон CIDR" />,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.cidr}</span>,
|
||||
meta: {
|
||||
headerTitle: "Диапазон CIDR",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "allocated",
|
||||
accessorKey: "allocated",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Назначено /30" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums text-right block">{row.original.allocated}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Назначено /30",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "available",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground block text-right">
|
||||
Доступно /30
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const p = row.original
|
||||
return (
|
||||
<span className="tabular-nums text-muted-foreground text-right block">
|
||||
{p.total - p.allocated}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "usage",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Использование</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const pool = row.original
|
||||
const pct = pool.total > 0 ? Math.round((pool.allocated / pool.total) * 100) : 0
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-[140px]">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full", pct > 80 ? "bg-amber-500" : "bg-emerald-500")}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums w-8 text-right">{pct}%</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "comment",
|
||||
accessorKey: "comment",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs">{row.original.comment}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Назначение",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
enableSorting: false,
|
||||
cell: () => (
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||
"opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100",
|
||||
"data-popup-open:opacity-100",
|
||||
)}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem>
|
||||
<PencilIcon className="size-4" /> Редактировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<Trash2Icon className="size-4" /> Удалить пул
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
),
|
||||
size: 56,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: pools,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (pools.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<LayersIcon className="size-4" />}
|
||||
title="Нет IP-пулов"
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={pools.length}
|
||||
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row" }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { GrePoolsDataGrid, type GrePoolsDataGridProps }
|
||||
@@ -0,0 +1,352 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type {
|
||||
GrePool,
|
||||
GreTunnel,
|
||||
GreStatus,
|
||||
IpsecEncAlg,
|
||||
IpsecAuthAlg,
|
||||
IpsecDhGroup,
|
||||
IkeVersion,
|
||||
Server,
|
||||
} from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
CodeXmlIcon,
|
||||
LockIcon,
|
||||
LockOpenIcon,
|
||||
MoreHorizontalIcon,
|
||||
NetworkIcon,
|
||||
PencilIcon,
|
||||
PowerIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
const ENC_LABELS: Record<IpsecEncAlg, string> = {
|
||||
"aes-128": "AES-128",
|
||||
"aes-192": "AES-192",
|
||||
"aes-256": "AES-256",
|
||||
}
|
||||
const AUTH_LABELS: Record<IpsecAuthAlg, string> = {
|
||||
sha1: "SHA-1",
|
||||
sha256: "SHA-256",
|
||||
sha512: "SHA-512",
|
||||
}
|
||||
const DH_LABELS: Record<IpsecDhGroup, string> = {
|
||||
modp1024: "DH-2 (1024)",
|
||||
modp2048: "DH-14 (2048)",
|
||||
modp4096: "DH-16 (4096)",
|
||||
ecp256: "ECP-256",
|
||||
ecp384: "ECP-384",
|
||||
ecp521: "ECP-521",
|
||||
}
|
||||
const IKE_LABELS: Record<IkeVersion, string> = { ikev1: "IKEv1", ikev2: "IKEv2" }
|
||||
|
||||
const STATUS_MAP: Record<GreStatus, { label: string; dot: string }> = {
|
||||
up: { label: "Up", dot: "bg-emerald-500" },
|
||||
degraded: { label: "Degraded", dot: "bg-amber-500" },
|
||||
down: { label: "Down", dot: "bg-red-500" },
|
||||
}
|
||||
|
||||
function TunnelStatus({ status }: { status: GreStatus }) {
|
||||
const s = STATUS_MAP[status]
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm">
|
||||
<span className={cn("size-1.5 rounded-full", s.dot)} />
|
||||
{s.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function IpsecBadge({ secured }: { secured: boolean }) {
|
||||
return secured ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium border rounded px-2 py-0.5 bg-emerald-500/10 text-emerald-400 border-emerald-500/20">
|
||||
<LockIcon className="size-3" /> IPsec
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium border rounded px-2 py-0.5 bg-muted text-muted-foreground border-border">
|
||||
<LockOpenIcon className="size-3" /> Открытый
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface GreTunnelsDataGridProps {
|
||||
tunnels: GreTunnel[]
|
||||
servers: Server[]
|
||||
pools: GrePool[]
|
||||
onCodePreview: (tunnel: GreTunnel) => void
|
||||
}
|
||||
|
||||
function GreTunnelsDataGrid({
|
||||
tunnels,
|
||||
servers,
|
||||
pools,
|
||||
onCodePreview,
|
||||
}: GreTunnelsDataGridProps) {
|
||||
const serverMap = useMemo(() => new Map(servers.map((s) => [s.id, s])), [servers])
|
||||
const poolMap = useMemo(() => new Map(pools.map((p) => [p.id, p])), [pools])
|
||||
|
||||
const columns = useMemo<ColumnDef<GreTunnel>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Интерфейс / Сервер" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const t = row.original
|
||||
const srv = serverMap.get(t.serverId)
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium font-mono text-[13px]">{t.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
|
||||
{srv && <Flag code={srv.country} />}
|
||||
{srv?.name ?? t.serverId}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Интерфейс / Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "endpoints",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Эндпоинты</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const t = row.original
|
||||
return (
|
||||
<div>
|
||||
<p className="font-mono text-xs">
|
||||
{t.localAddress === "0.0.0.0" ? (
|
||||
<span className="text-muted-foreground">авто</span>
|
||||
) : (
|
||||
t.localAddress
|
||||
)}
|
||||
</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">→ {t.remoteAddress}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "innerIp",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Внутренний IP</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const t = row.original
|
||||
return (
|
||||
<div>
|
||||
<p className="font-mono text-xs">{t.localInnerIp}</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">{t.remoteInnerIp}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "pool",
|
||||
accessorKey: "poolId",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Пул" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{poolMap.get(row.original.poolId)?.name ?? "—"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: "Пул", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "ipsec",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">IPsec</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <IpsecBadge secured={!!row.original.ipsec} />,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "encryption",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Шифрование</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const t = row.original
|
||||
if (!t.ipsec) return <span className="text-xs text-muted-foreground">—</span>
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-mono">
|
||||
{ENC_LABELS[t.ipsec.encAlg]} / {AUTH_LABELS[t.ipsec.authAlg]}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{DH_LABELS[t.ipsec.dhGroup].split(" ")[0]} · {IKE_LABELS[t.ipsec.ikeVersion]}
|
||||
{t.ipsec.pfs && " · PFS"}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "mtu",
|
||||
accessorKey: "mtu",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="MTU" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-center block">{row.original.mtu}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "MTU",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "keepalive",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Keepalive</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const t = row.original
|
||||
return (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{t.keepaliveInterval === 0 ? "откл." : `${t.keepaliveInterval}с / ${t.keepaliveRetries}`}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => <TunnelStatus status={row.original.status} />,
|
||||
meta: { headerTitle: "Статус", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const t = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-7 opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100",
|
||||
)}
|
||||
title="Предпросмотр кода RouterOS"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onCodePreview(t)
|
||||
}}
|
||||
>
|
||||
<CodeXmlIcon className="size-3.5" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||
"opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100",
|
||||
"data-popup-open:opacity-100",
|
||||
)}
|
||||
aria-label={`Действия: ${t.name}`}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>{t.name}</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onCodePreview(t)}>
|
||||
<CodeXmlIcon className="size-4" /> Просмотр кода
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<PencilIcon className="size-4" /> Редактировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<PowerIcon className="size-4" />
|
||||
{t.enabled ? "Выключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<Trash2Icon className="size-4" /> Удалить туннель
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 88,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[onCodePreview, poolMap, serverMap],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: tunnels,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row, index) => `${row.id}:${row.serverId}:${row.name}:${index}`,
|
||||
})
|
||||
|
||||
if (tunnels.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title="Нет GRE-туннелей"
|
||||
description="Измените фильтр или добавьте туннель"
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={tunnels.length}
|
||||
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row" }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { GreTunnelsDataGrid, type GreTunnelsDataGridProps }
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { IpRange } from "@/lib/data"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { FilterIcon, NetworkIcon } from "lucide-react"
|
||||
|
||||
interface IpRangesDataGridProps {
|
||||
ipRanges: IpRange[]
|
||||
isLoading?: boolean
|
||||
pagination?: boolean
|
||||
}
|
||||
|
||||
function IpRangesDataGrid({ ipRanges, isLoading, pagination = false }: IpRangesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<IpRange>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "cidr",
|
||||
accessorKey: "cidr",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="CIDR" className="ml-1" />,
|
||||
cell: ({ row }) => <span className="font-mono font-medium">{row.original.cidr}</span>,
|
||||
meta: {
|
||||
headerTitle: "CIDR",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "asn",
|
||||
accessorKey: "asn",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="ASN" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.asn}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "ASN",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "country",
|
||||
accessorKey: "country",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Страна" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs border border-border rounded px-2 py-0.5">{row.original.country}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Страна",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "purpose",
|
||||
accessorKey: "purpose",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs border border-border rounded px-2 py-0.5">{row.original.purpose}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Назначение",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "filter",
|
||||
accessorKey: "filter",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Фильтр" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />
|
||||
{row.original.filter}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Фильтр",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
accessorKey: "updated",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Обновлён" />,
|
||||
cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.original.updated}</span>,
|
||||
meta: {
|
||||
headerTitle: "Обновлён",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={`text-xs font-medium ${row.original.enabled ? "text-emerald-600" : "text-muted-foreground"}`}
|
||||
>
|
||||
{row.original.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: ipRanges,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
...(pagination ? { getPaginationRowModel: getPaginationRowModel() } : {}),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={ipRanges.length}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
pagination={pagination}
|
||||
emptyMessage={
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title="Нет IP-диапазонов"
|
||||
description="Добавьте CIDR-блоки или импортируйте каталог"
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { IpRangesDataGrid, type IpRangesDataGridProps }
|
||||
@@ -0,0 +1,298 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { ActivityIcon } from "lucide-react"
|
||||
|
||||
export interface BfdSessionRow {
|
||||
id: string
|
||||
serverId: string
|
||||
serverLabel: string
|
||||
localAddr: string
|
||||
remoteAddr: string
|
||||
state: "Up" | "Down" | "Init" | "AdminDown"
|
||||
interval: number
|
||||
multiplier: number
|
||||
iface: string
|
||||
uptime: string | null
|
||||
multihop: boolean
|
||||
rxInterval: number
|
||||
holdTime: number
|
||||
packetsRx: number
|
||||
packetsTx: number
|
||||
stateChanges: number
|
||||
}
|
||||
|
||||
function stateClass(state: BfdSessionRow["state"]) {
|
||||
if (state === "Up")
|
||||
return "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/25"
|
||||
if (state === "Init")
|
||||
return "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/25"
|
||||
return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25"
|
||||
}
|
||||
|
||||
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border",
|
||||
color ?? "bg-muted text-muted-foreground border-border",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtMs(ms: number) {
|
||||
if (!ms) return "—"
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
interface OspfBfdDataGridProps {
|
||||
sessions: BfdSessionRow[]
|
||||
}
|
||||
|
||||
function OspfBfdDataGrid({ sessions }: OspfBfdDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<BfdSessionRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "serverLabel",
|
||||
accessorKey: "serverLabel",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Роутер" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const b = row.original
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono whitespace-nowrap">{b.serverLabel}</span>
|
||||
{b.multihop && (
|
||||
<Chip color="bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20">
|
||||
multihop
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Роутер",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "iface",
|
||||
accessorKey: "iface",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-muted-foreground whitespace-nowrap">
|
||||
{row.original.iface || "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Интерфейс",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "localAddr",
|
||||
accessorKey: "localAddr",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Локальный" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono whitespace-nowrap">{row.original.localAddr}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Локальный",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "remoteAddr",
|
||||
accessorKey: "remoteAddr",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Удалённый" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono whitespace-nowrap">{row.original.remoteAddr}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Удалённый",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
accessorKey: "state",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium",
|
||||
stateClass(row.original.state),
|
||||
)}
|
||||
>
|
||||
{row.original.state}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Состояние",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
accessorKey: "uptime",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Uptime" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground whitespace-nowrap tabular-nums">
|
||||
{row.original.uptime ?? "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Uptime",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "intervals",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Tx / Rx</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original
|
||||
return (
|
||||
<span className="font-mono tabular-nums text-muted-foreground whitespace-nowrap">
|
||||
{fmtMs(b.interval)} / {fmtMs(b.rxInterval)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "holdTime",
|
||||
accessorKey: "holdTime",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Hold" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-muted-foreground whitespace-nowrap">
|
||||
{fmtMs(row.original.holdTime)}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Hold",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "multiplier",
|
||||
accessorKey: "multiplier",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Mult" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-center block">{row.original.multiplier}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Mult",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "packetsRx",
|
||||
accessorKey: "packetsRx",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Пакеты Rx" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-right text-muted-foreground block">
|
||||
{row.original.packetsRx.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Пакеты Rx",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "packetsTx",
|
||||
accessorKey: "packetsTx",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Пакеты Tx" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-right text-muted-foreground block">
|
||||
{row.original.packetsTx.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Пакеты Tx",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "stateChanges",
|
||||
accessorKey: "stateChanges",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Переходы" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-right block">{row.original.stateChanges}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Переходы",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-right"),
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: sessions,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ActivityIcon className="size-4" />}
|
||||
title="BFD-сессий не обнаружено"
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={sessions.length}
|
||||
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row hover:bg-muted/30" }}
|
||||
tableLayout={{ dense: true }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { OspfBfdDataGrid, type OspfBfdDataGridProps }
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { NetworkIcon } from "lucide-react"
|
||||
|
||||
export interface OspfNeighborRow {
|
||||
id: string
|
||||
localRouter: string
|
||||
localLabel: string
|
||||
localIface: string
|
||||
remoteRouter: string
|
||||
remoteLabel: string
|
||||
remoteRouterId: string
|
||||
area: string
|
||||
state: "Full" | "2-Way" | "ExStart" | "Down"
|
||||
cost: number
|
||||
uptime: string
|
||||
priority: number
|
||||
}
|
||||
|
||||
function stateClass(state: OspfNeighborRow["state"]) {
|
||||
if (state === "Full")
|
||||
return "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/25"
|
||||
if (state === "2-Way")
|
||||
return "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/25"
|
||||
return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25"
|
||||
}
|
||||
|
||||
interface OspfNeighborsDataGridProps {
|
||||
neighbors: OspfNeighborRow[]
|
||||
highlightRouterId?: string | null
|
||||
selectedRouterId?: string | null
|
||||
onHighlight?: (routerId: string | null) => void
|
||||
onSelect?: (routerId: string | null) => void
|
||||
}
|
||||
|
||||
function OspfNeighborsDataGrid({
|
||||
neighbors,
|
||||
highlightRouterId,
|
||||
selectedRouterId,
|
||||
onHighlight,
|
||||
onSelect,
|
||||
}: OspfNeighborsDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<OspfNeighborRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "localLabel",
|
||||
accessorKey: "localLabel",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Роутер" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono whitespace-nowrap">{row.original.localLabel}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Роутер",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "localIface",
|
||||
accessorKey: "localIface",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-muted-foreground whitespace-nowrap">
|
||||
{row.original.localIface}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Интерфейс",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "remote",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground">Сосед (Router ID)</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const n = row.original
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono">
|
||||
{n.remoteLabel !== n.remoteRouterId ? n.remoteLabel : n.remoteRouterId}
|
||||
</span>
|
||||
{n.remoteLabel !== n.remoteRouterId && (
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{n.remoteRouterId}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "area",
|
||||
accessorKey: "area",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Область" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-muted-foreground">{row.original.area}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Область",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
accessorKey: "state",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium",
|
||||
stateClass(row.original.state),
|
||||
)}
|
||||
>
|
||||
{row.original.state}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Состояние",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cost",
|
||||
accessorKey: "cost",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Cost" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-center block">{row.original.cost}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Cost",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
accessorKey: "uptime",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Uptime" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground whitespace-nowrap tabular-nums">
|
||||
{row.original.uptime}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Uptime",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "priority",
|
||||
accessorKey: "priority",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Prio" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-center font-mono block">{row.original.priority}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Prio",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-center"),
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const rowSelection = useMemo(() => {
|
||||
if (!selectedRouterId) return {}
|
||||
const match = neighbors.find(
|
||||
(n) => n.localRouter === selectedRouterId || n.remoteRouter === selectedRouterId,
|
||||
)
|
||||
return match ? { [match.id]: true } : {}
|
||||
}, [neighbors, selectedRouterId])
|
||||
|
||||
const table = useReactTable({
|
||||
data: neighbors,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
enableRowSelection: true,
|
||||
state: { rowSelection },
|
||||
})
|
||||
|
||||
if (neighbors.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title="Нет OSPF-соседей"
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={neighbors.length}
|
||||
onRowClick={(row) => {
|
||||
onSelect?.(selectedRouterId === row.localRouter ? null : row.localRouter)
|
||||
}}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn(
|
||||
"group/row cursor-pointer",
|
||||
"data-[state=selected]:bg-primary/5",
|
||||
highlightRouterId && "[&:hover]:bg-muted/30",
|
||||
),
|
||||
}}
|
||||
tableLayout={{ dense: true }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { OspfNeighborsDataGrid, type OspfNeighborsDataGridProps }
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { RouteIcon } from "lucide-react"
|
||||
|
||||
export interface OspfRouteRow {
|
||||
id: string
|
||||
destination: string
|
||||
type: "O" | "O IA" | "O E1" | "O E2"
|
||||
cost: number
|
||||
nextHop: string
|
||||
via: string
|
||||
serverId: string
|
||||
serverLabel: string
|
||||
area: string
|
||||
}
|
||||
|
||||
function routeTypeClass(type: OspfRouteRow["type"]) {
|
||||
if (type === "O") return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25"
|
||||
if (type === "O IA") return "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/25"
|
||||
if (type === "O E1") return "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/25"
|
||||
return "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/25"
|
||||
}
|
||||
|
||||
interface OspfRoutesDataGridProps {
|
||||
routes: OspfRouteRow[]
|
||||
}
|
||||
|
||||
function OspfRoutesDataGrid({ routes }: OspfRoutesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<OspfRouteRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "destination",
|
||||
accessorKey: "destination",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Назначение" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => <span className="font-mono">{row.original.destination}</span>,
|
||||
meta: {
|
||||
headerTitle: "Назначение",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium",
|
||||
routeTypeClass(row.original.type),
|
||||
)}
|
||||
>
|
||||
{row.original.type}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: "Тип", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "cost",
|
||||
accessorKey: "cost",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Cost" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-center block">{row.original.cost}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Cost",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "nextHop",
|
||||
accessorKey: "nextHop",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Следующий хоп" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-muted-foreground">{row.original.nextHop}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Следующий хоп",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "via",
|
||||
accessorKey: "via",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-muted-foreground">{row.original.via}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Интерфейс",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "serverLabel",
|
||||
accessorKey: "serverLabel",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Роутер" />,
|
||||
cell: ({ row }) => <span className="font-mono">{row.original.serverLabel}</span>,
|
||||
meta: {
|
||||
headerTitle: "Роутер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "area",
|
||||
accessorKey: "area",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Область" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-muted-foreground">{row.original.area}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Область",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: routes,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (routes.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<RouteIcon className="size-4" />}
|
||||
title="Нет OSPF-маршрутов"
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={routes.length}
|
||||
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row hover:bg-muted/30" }}
|
||||
tableLayout={{ dense: true }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { OspfRoutesDataGrid, type OspfRoutesDataGridProps, routeTypeClass }
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Server } from "@/lib/data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { ClockIcon, Trash2Icon } from "lucide-react"
|
||||
|
||||
export type SchedType = "ping" | "bandwidth" | "both"
|
||||
|
||||
export interface SchedRule {
|
||||
id: string
|
||||
srcId: string
|
||||
tunnelId: string
|
||||
type: SchedType
|
||||
intervalMin: number
|
||||
enabled: boolean
|
||||
lastRun: string | null
|
||||
nextRunMin: number | null
|
||||
}
|
||||
|
||||
const TYPE_LABEL: Record<SchedType, string> = {
|
||||
ping: "Ping",
|
||||
bandwidth: "BW-тест",
|
||||
both: "Ping + BW",
|
||||
}
|
||||
|
||||
interface ProbesScheduleDataGridProps {
|
||||
rules: SchedRule[]
|
||||
serverOptions: Server[]
|
||||
tunnelName: (srcId: string, tunnelId: string) => string | undefined
|
||||
onToggleEnabled: (id: string, enabled: boolean) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
function ProbesScheduleDataGrid({
|
||||
rules,
|
||||
serverOptions,
|
||||
tunnelName,
|
||||
onToggleEnabled,
|
||||
onDelete,
|
||||
}: ProbesScheduleDataGridProps) {
|
||||
const serverMap = useMemo(
|
||||
() => new Map(serverOptions.map((s) => [s.id, s])),
|
||||
[serverOptions],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<SchedRule>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: () => <span className="sr-only">Вкл</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<FormToggle
|
||||
checked={row.original.enabled}
|
||||
onChange={(v) => onToggleEnabled(row.original.id, v)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
size: 48,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tunnel",
|
||||
accessorKey: "tunnelId",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Туннель" />,
|
||||
cell: ({ row }) => (
|
||||
<code className="font-mono text-xs truncate block">
|
||||
{tunnelName(row.original.srcId, row.original.tunnelId) ?? row.original.tunnelId}
|
||||
</code>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Туннель",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "server",
|
||||
accessorKey: "srcId",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
{serverMap.get(row.original.srcId)?.name ?? row.original.srcId}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] px-1.5 py-0.5 rounded border font-medium w-fit",
|
||||
row.original.type === "ping"
|
||||
? "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20"
|
||||
: row.original.type === "bandwidth"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
|
||||
)}
|
||||
>
|
||||
{TYPE_LABEL[row.original.type]}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Тип",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "intervalMin",
|
||||
accessorKey: "intervalMin",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Интервал" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">каждые {row.original.intervalMin} мин</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Интервал",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "lastRun",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground">Последний / следующий</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const rule = row.original
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2 min-w-0">
|
||||
{rule.lastRun && <span className="truncate">{rule.lastRun}</span>}
|
||||
{rule.nextRunMin != null && rule.enabled && (
|
||||
<span className="text-sky-600 dark:text-sky-400 shrink-0">
|
||||
· через {rule.nextRunMin} мин
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Последний / следующий",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(row.original.id)}
|
||||
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-red-500 hover:bg-red-500/10 transition-colors opacity-0 group-hover/row:opacity-100 focus-visible:opacity-100"
|
||||
aria-label="Удалить правило"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</button>
|
||||
),
|
||||
size: 48,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[onDelete, onToggleEnabled, serverMap, tunnelName],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: rules,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (rules.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ClockIcon className="size-4" />}
|
||||
title="Нет правил расписания"
|
||||
description="Добавьте правило ping или bandwidth-теста"
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <DataGridShell table={table} recordCount={rules.length} />
|
||||
}
|
||||
|
||||
export { ProbesScheduleDataGrid, type ProbesScheduleDataGridProps }
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { ClockIcon } from "lucide-react"
|
||||
|
||||
export interface SpeedProbeApiRow {
|
||||
id: string
|
||||
srcServerId: string
|
||||
dstServerId: string
|
||||
srcInterface: string
|
||||
dstInterface: string
|
||||
protocol: string
|
||||
direction: string
|
||||
durationSec: string
|
||||
enabled: boolean
|
||||
lastRunAt: string | null
|
||||
lastTxAvgMbps: number | null
|
||||
lastRxAvgMbps: number | null
|
||||
lastStatus: string | null
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
interface ProbesSpeedProbesDataGridProps {
|
||||
rows: SpeedProbeApiRow[]
|
||||
serverName: (id: string) => string
|
||||
}
|
||||
|
||||
function ProbesSpeedProbesDataGrid({ rows, serverName }: ProbesSpeedProbesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<SpeedProbeApiRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "src",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Источник" className="ml-1" />,
|
||||
accessorFn: (row) => row.srcServerId,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<span className="truncate font-mono text-xs block">
|
||||
{serverName(r.srcServerId)}
|
||||
{r.srcInterface ? ` · ${r.srcInterface}` : ""}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Источник",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dst",
|
||||
accessorFn: (row) => row.dstServerId,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<span className="truncate font-mono text-xs block">
|
||||
{serverName(r.dstServerId)}
|
||||
{r.dstInterface ? ` · ${r.dstInterface}` : ""}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Назначение",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "protocol",
|
||||
accessorKey: "protocol",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Протокол" />,
|
||||
cell: ({ row }) => <span className="text-xs">{row.original.protocol.toUpperCase()}</span>,
|
||||
meta: {
|
||||
headerTitle: "Протокол",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "durationSec",
|
||||
accessorKey: "durationSec",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сек" />,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.durationSec}s</span>,
|
||||
meta: {
|
||||
headerTitle: "Сек",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Вкл" />,
|
||||
cell: ({ row }) => <span className="text-xs">{row.original.enabled ? "да" : "нет"}</span>,
|
||||
meta: {
|
||||
headerTitle: "Вкл",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "lastRunAt",
|
||||
accessorKey: "lastRunAt",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Последний запуск" />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
{r.lastRunAt ?? "—"}
|
||||
{r.lastStatus === "done" && r.lastTxAvgMbps != null && (
|
||||
<span className="text-emerald-600 dark:text-emerald-400 ml-1">
|
||||
TX≈{r.lastTxAvgMbps.toFixed(1)} RX≈{(r.lastRxAvgMbps ?? 0).toFixed(1)} Mb/s
|
||||
</span>
|
||||
)}
|
||||
{r.lastStatus === "error" && r.lastError && (
|
||||
<span className="text-destructive ml-1 truncate">{r.lastError}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Последний запуск",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[serverName],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ClockIcon className="size-4" />}
|
||||
title="Нет записей speed-test"
|
||||
description="Настраиваются через API PUT /api/uptime/speed-probes"
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rows.length}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { ProbesSpeedProbesDataGrid, type ProbesSpeedProbesDataGridProps }
|
||||
@@ -0,0 +1,328 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
PencilIcon,
|
||||
RouteIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export interface RecursiveRouteEndpoint {
|
||||
id: string
|
||||
gateway: string
|
||||
distance: number
|
||||
scope: number | null
|
||||
targetScope: number | null
|
||||
checkGateway: string
|
||||
country: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export interface RecursiveRouteGroup {
|
||||
id?: string
|
||||
key: string
|
||||
dstAddress: string
|
||||
routingTable: string
|
||||
comment: string
|
||||
endpoints: RecursiveRouteEndpoint[]
|
||||
}
|
||||
|
||||
const INFER_COUNTRIES = [
|
||||
{ code: "RU", keys: ["MSK", "SPB", "RTK", "MTS", "VPSVILLE", "IHOR"] },
|
||||
{ code: "SE", keys: ["SWE", "STO"] },
|
||||
{ code: "FI", keys: ["HEL", "FIN"] },
|
||||
{ code: "DE", keys: ["FRA", "GER", "DE"] },
|
||||
{ code: "NL", keys: ["AMS", "NLD", "NL"] },
|
||||
{ code: "SG", keys: ["SGP", "SIN", "SG"] },
|
||||
{ code: "TR", keys: ["TUR", "TR"] },
|
||||
{ code: "US", keys: ["USA", "US", "NYC", "LAX"] },
|
||||
]
|
||||
|
||||
function inferCountry(name: string): string | null {
|
||||
const upper = name.toUpperCase()
|
||||
for (const c of INFER_COUNTRIES) {
|
||||
if (c.keys.some((k) => upper.includes(k))) return c.code
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function RouteGroupExpandedDetail({ group }: { group: RecursiveRouteGroup }) {
|
||||
const sorted = [...group.endpoints].sort((a, b) => a.distance - b.distance)
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-5 py-4 bg-muted/20">
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
Route: <span className="font-mono text-foreground">{group.dstAddress}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Table:{" "}
|
||||
<span className="font-mono text-foreground">{group.routingTable || "main"}</span>
|
||||
</span>
|
||||
{group.comment && <span className="text-muted-foreground italic">{group.comment}</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5">
|
||||
{sorted.map((ep, idx) => (
|
||||
<div key={ep.id} className="rounded-lg border border-border bg-background px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(ep.country || inferCountry(ep.gateway)) && (
|
||||
<Flag code={ep.country || inferCountry(ep.gateway) || ""} size={16} />
|
||||
)}
|
||||
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Endpoint {idx + 1}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-mono">distance: {ep.distance}</span>
|
||||
</div>
|
||||
<p className="mt-1.5 font-mono text-sm break-all leading-tight">{ep.gateway}</p>
|
||||
<div className="mt-1.5 text-[11px] text-muted-foreground flex items-center gap-3">
|
||||
<span>scope: {ep.scope ?? "—"}</span>
|
||||
<span>t.scope: {ep.targetScope ?? "—"}</span>
|
||||
<span>check: {ep.checkGateway || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface RecursiveRoutesDataGridProps {
|
||||
groups: RecursiveRouteGroup[]
|
||||
expandedKey?: string | null
|
||||
onExpandedChange?: (key: string | null) => void
|
||||
onEdit: (group: RecursiveRouteGroup) => void
|
||||
onDelete: (group: RecursiveRouteGroup) => void
|
||||
}
|
||||
|
||||
function RecursiveRoutesDataGrid({
|
||||
groups,
|
||||
expandedKey,
|
||||
onExpandedChange,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: RecursiveRoutesDataGridProps) {
|
||||
const [confirmDeleteKey, setConfirmDeleteKey] = useState<string | null>(null)
|
||||
|
||||
const expanded = useMemo(() => {
|
||||
if (!expandedKey) return {}
|
||||
const g = groups.find((x) => x.key === expandedKey)
|
||||
return g ? { [(g.id ?? g.key)]: true } : {}
|
||||
}, [expandedKey, groups])
|
||||
|
||||
const columns = useMemo<ColumnDef<RecursiveRouteGroup>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "route",
|
||||
accessorKey: "dstAddress",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Route / Comment" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const g = row.original
|
||||
const isExpanded = expandedKey === g.key
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{g.dstAddress}</p>
|
||||
<p className="text-xs font-mono text-muted-foreground">{g.comment || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Route / Comment",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
expandedContent: (group: RecursiveRouteGroup) => (
|
||||
<RouteGroupExpandedDetail group={group} />
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gateways",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Gateways</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const sorted = [...row.original.endpoints].sort((a, b) => a.distance - b.distance)
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{sorted.map((ep, idx) => {
|
||||
const code = ep.country || inferCountry(ep.gateway)
|
||||
return (
|
||||
<div key={ep.id} className="flex items-center gap-1.5 text-[11px] font-mono">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full shrink-0",
|
||||
idx === 0 ? "bg-emerald-500" : "bg-sky-500",
|
||||
)}
|
||||
/>
|
||||
{code ? (
|
||||
<Flag code={code} size={14} className="shrink-0" />
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground w-3.5 text-center shrink-0">
|
||||
?
|
||||
</span>
|
||||
)}
|
||||
<span className="font-semibold text-sky-600 dark:text-sky-400 truncate min-w-0">
|
||||
{ep.gateway}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "epCount",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">EP</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs tabular-nums">{row.original.endpoints.length}</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "priority",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Priority</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const bestDistance = Math.min(...row.original.endpoints.map((ep) => ep.distance))
|
||||
return <span className="font-mono text-xs">d{bestDistance}</span>
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "table",
|
||||
accessorKey: "routingTable",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Table" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{row.original.routingTable || "main"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Table",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const g = row.original
|
||||
const confirmDel = confirmDeleteKey === g.key
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-0.5 justify-end opacity-0 group-hover/row:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="size-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onEdit(g)}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"size-7 p-0 transition-colors",
|
||||
confirmDel
|
||||
? "text-destructive bg-destructive/10 hover:bg-destructive/20"
|
||||
: "text-muted-foreground hover:text-destructive",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!confirmDel) setConfirmDeleteKey(g.key)
|
||||
else {
|
||||
onDelete(g)
|
||||
setConfirmDeleteKey(null)
|
||||
}
|
||||
}}
|
||||
onBlur={() => setConfirmDeleteKey(null)}
|
||||
>
|
||||
{confirmDel ? (
|
||||
<AlertCircleIcon className="size-3.5" />
|
||||
) : (
|
||||
<TrashIcon className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 80,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[confirmDeleteKey, expandedKey, onDelete, onEdit],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: groups,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getRowId: (row) => row.id ?? row.key,
|
||||
getRowCanExpand: () => true,
|
||||
state: { expanded },
|
||||
})
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<RouteIcon className="size-4" />}
|
||||
title="Нет маршрутов"
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={groups.length}
|
||||
onRowClick={(row) => {
|
||||
onExpandedChange?.(expandedKey === row.key ? null : row.key)
|
||||
}}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row cursor-pointer", expandedKey && "data-[state=selected]:bg-muted/30"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { RecursiveRoutesDataGrid, type RecursiveRoutesDataGridProps, inferCountry }
|
||||
@@ -0,0 +1,259 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { CommRec } from "@/lib/route-optimizer-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { ArrowRightIcon, PinIcon, RefreshCwIcon } from "lucide-react"
|
||||
|
||||
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border",
|
||||
color ?? "bg-muted text-muted-foreground border-border",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbChip({ prob, best }: { prob: number; best?: boolean }) {
|
||||
return (
|
||||
<Chip
|
||||
color={
|
||||
best
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-muted text-muted-foreground border-border"
|
||||
}
|
||||
>
|
||||
{prob}%
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
|
||||
interface RouteOptimizerCommRecsDataGridProps {
|
||||
recs: CommRec[]
|
||||
homeId: string
|
||||
pinned: Set<string>
|
||||
applied: Set<string>
|
||||
applying: Set<string>
|
||||
onPin: (key: string) => void
|
||||
onApply: (community: string, homeId: string) => void
|
||||
}
|
||||
|
||||
function RouteOptimizerCommRecsDataGrid({
|
||||
recs,
|
||||
homeId,
|
||||
pinned,
|
||||
applied,
|
||||
applying,
|
||||
onPin,
|
||||
onApply,
|
||||
}: RouteOptimizerCommRecsDataGridProps) {
|
||||
const rows = useMemo(
|
||||
() => recs.map((r, idx) => ({ ...r, _rowKey: `${homeId}::${r.community}::${idx}` })),
|
||||
[homeId, recs],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<(typeof rows)[number]>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "community",
|
||||
accessorKey: "community",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Community" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<div className="font-mono text-xs font-medium">{row.original.community}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{row.original.communityName}</div>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Community",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "current",
|
||||
header: () => (
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
Текущий (WAN → JH → Exit)
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
if (!r.current) return <span className="text-muted-foreground text-xs">—</span>
|
||||
return (
|
||||
<div className="text-xs flex items-center gap-1 flex-wrap">
|
||||
<span className="font-mono font-medium text-sky-600 dark:text-sky-400">
|
||||
{r.current.wan}
|
||||
</span>
|
||||
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<span>{r.current.jh}</span>
|
||||
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">{r.current.exit}</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
({r.current.gateway})
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "recommended",
|
||||
header: () => <span className="text-[11px] font-medium text-muted-foreground">Рекомендуемый</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const pinKey = `${homeId}::${r.community}`
|
||||
const isPinned = pinned.has(pinKey)
|
||||
if (!r.recommended) return <span className="text-muted-foreground text-xs">—</span>
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs flex items-center gap-1 flex-wrap",
|
||||
r.shouldSwitch && !isPinned && "text-amber-600 dark:text-amber-400",
|
||||
)}
|
||||
>
|
||||
<span className="font-mono font-medium">{r.recommended.wan}</span>
|
||||
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
|
||||
<span>{r.recommended.jh}</span>
|
||||
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
|
||||
<span>{r.recommended.exit}</span>
|
||||
{r.shouldSwitch && !isPinned && (
|
||||
<span className="ml-1 text-[10px] font-bold bg-amber-500/10 border border-amber-500/20 px-1.5 py-0.5 rounded">
|
||||
+{(r.recommended.prob ?? 0) - (r.current?.prob ?? 0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "probability",
|
||||
header: () => (
|
||||
<span className="text-[11px] font-medium text-muted-foreground block text-center">
|
||||
P(тек / рек)
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const pinKey = `${homeId}::${r.community}`
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<ProbChip prob={r.current?.prob ?? 0} />
|
||||
<span className="text-muted-foreground text-[10px]">/</span>
|
||||
<ProbChip prob={r.recommended?.prob ?? 0} best={r.shouldSwitch && !pinned.has(pinKey)} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => (
|
||||
<span className="text-[11px] font-medium text-muted-foreground block text-right">
|
||||
Действие
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const pinKey = `${homeId}::${r.community}`
|
||||
const isPinned = pinned.has(pinKey)
|
||||
const isApplied = applied.has(pinKey)
|
||||
const isApplying = applying.has(pinKey)
|
||||
const canApply = r.shouldSwitch && !isPinned && !isApplied
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
{isPinned && <PinIcon className="size-3 text-sky-500 fill-sky-500" />}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn("h-7 text-xs", isPinned && "text-sky-600 dark:text-sky-400 border-sky-500/30")}
|
||||
onClick={() => onPin(pinKey)}
|
||||
>
|
||||
<PinIcon className={cn("size-3", isPinned && "fill-current")} />
|
||||
{isPinned ? "Открепить" : "Закрепить"}
|
||||
</Button>
|
||||
{canApply && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={isApplying}
|
||||
onClick={() => onApply(r.community, homeId)}
|
||||
>
|
||||
{isApplying ? (
|
||||
<RefreshCwIcon className="size-3 animate-spin" />
|
||||
) : (
|
||||
"Применить"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{isApplied && (
|
||||
<span className="text-[10px] text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
✓ применено
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-right"),
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[applied, applying, homeId, onApply, onPin, pinned],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => row._rowKey,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rows.length}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b bg-muted/20",
|
||||
bodyRow: cn(
|
||||
"group/row hover:bg-muted/30",
|
||||
"[&:has([data-comm-switch=true])]:bg-amber-500/5",
|
||||
"[&:has([data-comm-applied=true])]:bg-emerald-500/5",
|
||||
),
|
||||
}}
|
||||
tableLayout={{ dense: true }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { RouteOptimizerCommRecsDataGrid, type RouteOptimizerCommRecsDataGridProps }
|
||||
@@ -0,0 +1,294 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { FullRoute } from "@/lib/route-optimizer-data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { ArrowRightIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border",
|
||||
color ?? "bg-muted text-muted-foreground border-border",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbChip({ prob, best }: { prob: number; best?: boolean }) {
|
||||
return (
|
||||
<Chip
|
||||
color={
|
||||
best
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-muted text-muted-foreground border-border"
|
||||
}
|
||||
>
|
||||
{prob}%
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfChip({ conf }: { conf: string }) {
|
||||
const map: Record<string, string> = {
|
||||
HIGH: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
MEDIUM: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||||
LOW: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||
}
|
||||
return <Chip color={map[conf] ?? map.LOW}>{conf}</Chip>
|
||||
}
|
||||
|
||||
interface RouteOptimizerFullRoutesDataGridProps {
|
||||
routes: FullRoute[]
|
||||
bestId?: string
|
||||
}
|
||||
|
||||
function RouteOptimizerFullRoutesDataGrid({
|
||||
routes,
|
||||
bestId,
|
||||
}: RouteOptimizerFullRoutesDataGridProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const visibleRoutes = expanded ? routes : routes.slice(0, 5)
|
||||
|
||||
const indexed = useMemo(
|
||||
() => visibleRoutes.map((r, index) => ({ ...r, _index: index })),
|
||||
[visibleRoutes],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<FullRoute & { _index: number }>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "index",
|
||||
header: () => <span className="text-[11px] font-medium text-muted-foreground"># Маршрут</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const isBest = row.original.id === bestId || row.original._index === 0
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-mono text-muted-foreground w-4">
|
||||
{row.original._index + 1}
|
||||
</span>
|
||||
{isBest && (
|
||||
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-1.5 py-0.5 rounded">
|
||||
Лучший
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||
},
|
||||
{
|
||||
id: "wanJh",
|
||||
header: () => <span className="text-[11px] font-medium text-muted-foreground">WAN → JH</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="font-mono font-semibold text-sky-600 dark:text-sky-400">{r.wan.name}</span>
|
||||
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">{r.jh.label}</div>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">
|
||||
{r.hw.pingMs} мс · ↓{r.hw.dlMbps} ↑{r.hw.ulMbps}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "jhExit",
|
||||
header: () => <span className="text-[11px] font-medium text-muted-foreground">JH → Exit</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<div className="text-xs">
|
||||
<div className="flex items-center gap-1 font-medium">
|
||||
<Flag code={r.exit.country} />
|
||||
{r.exit.label}
|
||||
<span className="text-[10px] text-muted-foreground">({r.exit.site})</span>
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">
|
||||
{r.je.pingMs} мс · ↓{r.je.dlMbps} ↑{r.je.ulMbps}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "totalPing",
|
||||
accessorFn: (row) => row.hw.pingMs + row.je.pingMs,
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Ping (итого)" className="mx-auto" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const totalPing = row.original.hw.pingMs + row.original.je.pingMs
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-xs text-center block",
|
||||
totalPing < 40
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: totalPing < 80
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-red-500",
|
||||
)}
|
||||
>
|
||||
{totalPing} мс
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Ping (итого)",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bw",
|
||||
header: () => (
|
||||
<span className="text-[11px] font-medium text-muted-foreground block text-center">
|
||||
BW (мин)
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const minDl = Math.min(r.hw.dlMbps, r.je.dlMbps)
|
||||
const minUl = Math.min(r.hw.ulMbps, r.je.ulMbps)
|
||||
return (
|
||||
<div className="font-mono text-xs text-muted-foreground text-center">
|
||||
<div>↓{minDl}</div>
|
||||
<div>↑{minUl}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "score",
|
||||
accessorKey: "score",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Score" className="mx-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs font-semibold text-center block">
|
||||
{row.original.score}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Score",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prob",
|
||||
accessorKey: "probabilityOptimal",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="P(opt)" className="mx-auto" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const isBest = row.original.id === bestId || row.original._index === 0
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<ProbChip prob={row.original.probabilityOptimal} best={isBest} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "P(opt)",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "confidence",
|
||||
accessorKey: "confidence",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Conf." className="mx-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-center">
|
||||
<ConfChip conf={row.original.confidence} />
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Conf.",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-center"),
|
||||
},
|
||||
},
|
||||
],
|
||||
[bestId],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: indexed,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={visibleRoutes.length}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b bg-muted/20",
|
||||
bodyRow: cn("group/row hover:bg-muted/30", "has-[[data-route-best=true]]:bg-emerald-500/5"),
|
||||
}}
|
||||
tableLayout={{ dense: true }}
|
||||
/>
|
||||
{routes.length > 5 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="w-full py-2 text-xs text-muted-foreground hover:text-foreground transition-colors border-t flex items-center justify-center gap-1"
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronUpIcon className="size-3" />
|
||||
Свернуть
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDownIcon className="size-3" />
|
||||
Показать все {routes.length} комбинаций
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { RouteOptimizerFullRoutesDataGrid, type RouteOptimizerFullRoutesDataGridProps }
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { NetworkIcon } from "lucide-react"
|
||||
|
||||
export interface OspfPreviewInterfaceRow {
|
||||
id: string
|
||||
interface: string
|
||||
currentCost: number
|
||||
optimalCost: number
|
||||
score: number
|
||||
pingMs: number
|
||||
dlMbps: number
|
||||
ulMbps: number
|
||||
}
|
||||
|
||||
interface RouteOptimizerOspfPreviewDataGridProps {
|
||||
rows: OspfPreviewInterfaceRow[]
|
||||
error?: string | null
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
function RouteOptimizerOspfPreviewDataGrid({
|
||||
rows,
|
||||
error,
|
||||
loading,
|
||||
}: RouteOptimizerOspfPreviewDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<OspfPreviewInterfaceRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "interface",
|
||||
accessorKey: "interface",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Интерфейс" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => <span className="font-mono">{row.original.interface}</span>,
|
||||
meta: {
|
||||
headerTitle: "Интерфейс",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cost",
|
||||
accessorKey: "currentCost",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Cost" />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<span className="font-mono">
|
||||
<span className="text-sky-600 dark:text-sky-400">{r.currentCost}</span>
|
||||
{" → "}
|
||||
<span
|
||||
className={
|
||||
r.currentCost === r.optimalCost
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-amber-600 dark:text-amber-400"
|
||||
}
|
||||
>
|
||||
{r.optimalCost}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "Cost", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "score",
|
||||
accessorKey: "score",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Score" />,
|
||||
cell: ({ row }) => <span className="font-mono">{row.original.score}</span>,
|
||||
meta: { headerTitle: "Score", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "pingMs",
|
||||
accessorKey: "pingMs",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Ping" />,
|
||||
cell: ({ row }) => <span className="font-mono">{row.original.pingMs}ms</span>,
|
||||
meta: { headerTitle: "Ping", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "speed",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Speed (dl/ul)</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<span className="font-mono">
|
||||
↓{r.dlMbps} / ↑{r.ulMbps}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p className="px-3 py-2 text-xs text-destructive">Ошибка preview: {error}</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (!loading && rows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title="Интерфейсы OSPF не найдены для выбранного сервера"
|
||||
className="border-0 py-6 text-xs"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rows.length}
|
||||
isLoading={loading}
|
||||
loadingMode="skeleton"
|
||||
tableClassNames={{ headerRow: "border-b bg-muted/30", bodyRow: "group/row" }}
|
||||
tableLayout={{ dense: true }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { RouteOptimizerOspfPreviewDataGrid, type RouteOptimizerOspfPreviewDataGridProps }
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { HomeRouter, JumpHost, WanJhLeg } from "@/lib/route-optimizer-data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { WifiIcon } from "lucide-react"
|
||||
|
||||
function LossChip({ loss }: { loss: number }) {
|
||||
if (loss === 0) return <span className="text-emerald-600 dark:text-emerald-400 text-[11px] font-mono">0%</span>
|
||||
return (
|
||||
<span className={cn("text-[11px] font-mono", loss > 1 ? "text-red-500" : "text-amber-500")}>
|
||||
{loss}%
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface RouteOptimizerWanMatrixDataGridProps {
|
||||
home: HomeRouter
|
||||
legs: WanJhLeg[]
|
||||
jumpHosts: JumpHost[]
|
||||
}
|
||||
|
||||
function RouteOptimizerWanMatrixDataGrid({
|
||||
home,
|
||||
legs,
|
||||
jumpHosts,
|
||||
}: RouteOptimizerWanMatrixDataGridProps) {
|
||||
const bestScore = legs.length ? Math.max(...legs.map((l) => l.score)) : 0
|
||||
|
||||
const columns = useMemo<ColumnDef<HomeRouter["wans"][number]>[]>(() => {
|
||||
const base: ColumnDef<HomeRouter["wans"][number]>[] = [
|
||||
{
|
||||
id: "wan",
|
||||
accessorKey: "name",
|
||||
header: () => <span className="text-[11px] font-medium text-muted-foreground">WAN-аплинк</span>,
|
||||
cell: ({ row }) => {
|
||||
const wan = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<WifiIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<div>
|
||||
<p className="font-mono text-xs font-semibold">{wan.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{wan.iface}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||
},
|
||||
{
|
||||
id: "isp",
|
||||
header: () => <span className="text-[11px] font-medium text-muted-foreground">ISP / IP</span>,
|
||||
cell: ({ row }) => {
|
||||
const wan = row.original
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-medium">{wan.isp}</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">{wan.ip}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "bandwidth",
|
||||
header: () => (
|
||||
<span className="text-[11px] font-medium text-muted-foreground block text-right">
|
||||
Макс. полоса
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const wan = row.original
|
||||
return (
|
||||
<div className="text-right">
|
||||
<p className="font-mono text-xs">↓{wan.maxDl}</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">↑{wan.maxUl} Мбит</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const jhCols: ColumnDef<HomeRouter["wans"][number]>[] = jumpHosts.map((jh) => ({
|
||||
id: `jh-${jh.id}`,
|
||||
header: () => (
|
||||
<div className="text-center">
|
||||
<div className="text-[11px] font-medium text-muted-foreground">{jh.label}</div>
|
||||
<div className="font-mono font-normal text-[10px] opacity-60 flex items-center justify-center gap-1">
|
||||
<Flag code={jh.country} />
|
||||
{jh.site} · {jh.ip}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const wan = row.original
|
||||
const leg = legs.find((l) => l.wanId === wan.id && l.jhId === jh.id)
|
||||
if (!leg) return <span className="text-center text-muted-foreground text-xs block">—</span>
|
||||
const isBest = leg.score === bestScore
|
||||
return (
|
||||
<div className={cn("text-center", isBest && "bg-emerald-500/5")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-0.5 rounded-md px-2 py-1.5 transition-colors",
|
||||
isBest ? "border border-emerald-500/20 bg-emerald-500/8" : "border border-transparent",
|
||||
)}
|
||||
>
|
||||
{isBest && (
|
||||
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 mb-0.5">
|
||||
★ ЛУЧШИЙ
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-xs font-semibold",
|
||||
leg.pingMs < 10
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: leg.pingMs < 25
|
||||
? "text-foreground"
|
||||
: "text-amber-600 dark:text-amber-400",
|
||||
)}
|
||||
>
|
||||
{leg.pingMs} мс
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
↓{leg.dlMbps} ↑{leg.ulMbps}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-[10px] font-mono text-foreground/70">score {leg.score}</span>
|
||||
{leg.loss > 0 && <LossChip loss={leg.loss} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "min-w-[130px] text-center"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "min-w-[130px]"),
|
||||
},
|
||||
}))
|
||||
|
||||
const last = jhCols[jhCols.length - 1]
|
||||
if (last?.meta) {
|
||||
last.meta.headerClassName = cn(last.meta.headerClassName, DATA_GRID_CELL_PAD_LAST.replace("py-3", ""))
|
||||
last.meta.cellClassName = DATA_GRID_CELL_PAD_LAST
|
||||
}
|
||||
|
||||
return [...base, ...jhCols]
|
||||
}, [bestScore, jumpHosts, legs])
|
||||
|
||||
const table = useReactTable({
|
||||
data: home.wans,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={home.wans.length}
|
||||
tableClassNames={{ headerRow: "border-b bg-muted/20", bodyRow: "group/row hover:bg-muted/30" }}
|
||||
tableLayout={{ dense: true }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { RouteOptimizerWanMatrixDataGrid, type RouteOptimizerWanMatrixDataGridProps }
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type Column,
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
@@ -21,11 +20,13 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DataGrid,
|
||||
DataGridContainer,
|
||||
DataGridTable,
|
||||
} from "@/components/reui/data-grid"
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { ServerExpandedDetail } from "@/components/data-grids/server-expanded-detail"
|
||||
import {
|
||||
@@ -42,9 +43,6 @@ import {
|
||||
RefreshCwIcon,
|
||||
PowerIcon,
|
||||
Trash2Icon,
|
||||
ArrowUpIcon,
|
||||
ArrowDownIcon,
|
||||
ArrowUpDownIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
const TYPE_LABELS: Record<ServerType, string> = {
|
||||
@@ -59,10 +57,6 @@ const TYPE_STYLES: Record<ServerType, string> = {
|
||||
"home-router": "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
|
||||
}
|
||||
|
||||
const CELL_PAD = "py-3"
|
||||
const CELL_PAD_FIRST = "pl-5 py-3"
|
||||
const CELL_PAD_LAST = "pr-4 py-3"
|
||||
|
||||
function rosVer(os: string): number {
|
||||
const m = os.match(/(\d+)\.(\d+)/)
|
||||
if (!m) return 0
|
||||
@@ -91,48 +85,6 @@ function RosBadge({ os }: { os: string }) {
|
||||
return <span className={cn("text-xs font-mono border rounded px-2 py-0.5", cls)}>{os}</span>
|
||||
}
|
||||
|
||||
function ServersTableHeader<TData>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
column: Column<TData, unknown>
|
||||
title: string
|
||||
className?: string
|
||||
}) {
|
||||
const sorted = column.getIsSorted()
|
||||
const canSort = column.getCanSort()
|
||||
|
||||
if (!canSort) {
|
||||
return (
|
||||
<span className={cn("text-xs font-medium text-muted-foreground", className)}>
|
||||
{title}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground",
|
||||
"hover:text-foreground transition-colors rounded-md -ml-1 px-1 py-0.5",
|
||||
className,
|
||||
)}
|
||||
onClick={column.getToggleSortingHandler()}
|
||||
>
|
||||
{title}
|
||||
{sorted === "asc" ? (
|
||||
<ArrowUpIcon className="size-3 text-foreground" />
|
||||
) : sorted === "desc" ? (
|
||||
<ArrowDownIcon className="size-3 text-foreground" />
|
||||
) : (
|
||||
<ArrowUpDownIcon className="size-3 opacity-35" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface ServerRowActionsProps {
|
||||
server: Server
|
||||
isLive: boolean
|
||||
@@ -234,7 +186,7 @@ function ServersDataGrid({
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<ServersTableHeader column={column} title="Имя / Хост" className="ml-1" />
|
||||
<DataGridSortHeader column={column} title="Имя / Хост" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const s = row.original
|
||||
@@ -258,8 +210,8 @@ function ServersDataGrid({
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Имя / Хост",
|
||||
headerClassName: CELL_PAD_FIRST,
|
||||
cellClassName: CELL_PAD_FIRST,
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
expandedContent: (row: Server) => (
|
||||
<ServerExpandedDetail
|
||||
server={row}
|
||||
@@ -273,35 +225,35 @@ function ServersDataGrid({
|
||||
{
|
||||
id: "type",
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => <ServersTableHeader column={column} title="Тип" />,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => <TypeBadge type={row.original.type} />,
|
||||
meta: { headerTitle: "Тип", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
|
||||
meta: { headerTitle: "Тип", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "model",
|
||||
accessorKey: "model",
|
||||
header: ({ column }) => <ServersTableHeader column={column} title="Модель" />,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Модель" />,
|
||||
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.model}</span>,
|
||||
meta: { headerTitle: "Модель", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
|
||||
meta: { headerTitle: "Модель", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "os",
|
||||
accessorKey: "os",
|
||||
header: ({ column }) => <ServersTableHeader column={column} title="RouterOS" />,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="RouterOS" />,
|
||||
cell: ({ row }) => <RosBadge os={row.original.os} />,
|
||||
meta: { headerTitle: "RouterOS", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
|
||||
meta: { headerTitle: "RouterOS", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "site",
|
||||
accessorKey: "site",
|
||||
header: ({ column }) => <ServersTableHeader column={column} title="Площадка" />,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Площадка" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Flag code={row.original.country} />
|
||||
<span className="font-medium text-sm">{row.original.site}</span>
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: "Площадка", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
|
||||
meta: { headerTitle: "Площадка", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "wan",
|
||||
@@ -345,13 +297,13 @@ function ServersDataGrid({
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "WAN / LAN", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
|
||||
meta: { headerTitle: "WAN / LAN", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "latency",
|
||||
accessorKey: "latency",
|
||||
header: ({ column }) => (
|
||||
<ServersTableHeader column={column} title="Задержка" className="w-full justify-end" />
|
||||
<DataGridSortHeader column={column} title="Задержка" className="w-full justify-end" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const s = row.original
|
||||
@@ -366,16 +318,16 @@ function ServersDataGrid({
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Задержка",
|
||||
headerClassName: cn(CELL_PAD, "text-right"),
|
||||
cellClassName: cn(CELL_PAD, "text-right"),
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <ServersTableHeader column={column} title="Статус" />,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
meta: { headerTitle: "Статус", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
|
||||
meta: { headerTitle: "Статус", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
@@ -397,8 +349,8 @@ function ServersDataGrid({
|
||||
enableSorting: false,
|
||||
size: 56,
|
||||
meta: {
|
||||
headerClassName: CELL_PAD_LAST,
|
||||
cellClassName: CELL_PAD_LAST,
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -427,25 +379,11 @@ function ServersDataGrid({
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={servers.length}
|
||||
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
||||
tableLayout={{
|
||||
rowBorder: true,
|
||||
headerBackground: true,
|
||||
headerBorder: true,
|
||||
columnsResizable: false,
|
||||
}}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: "group/row",
|
||||
}}
|
||||
>
|
||||
<DataGridContainer border={false} className="rounded-none border-0">
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
</DataGrid>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import type { Server } from "@/lib/data"
|
||||
import { CompactDataGrid, type CompactDataGridColumn } from "@/components/data-grids/compact-data-grid"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type PermLevel = "none" | "read" | "write"
|
||||
type Role = "admin" | "operator" | "viewer"
|
||||
|
||||
interface SectionPerm {
|
||||
section: string
|
||||
level: PermLevel
|
||||
}
|
||||
|
||||
interface ServerPerm {
|
||||
serverId: string
|
||||
level: PermLevel
|
||||
}
|
||||
|
||||
export interface AccessSummaryUser {
|
||||
id: string
|
||||
name: string
|
||||
avatar: string
|
||||
active: boolean
|
||||
role: Role
|
||||
sections: SectionPerm[]
|
||||
servers: ServerPerm[]
|
||||
}
|
||||
|
||||
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"size-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0",
|
||||
active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{avatar}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SettingsAccessSummaryDataGridProps {
|
||||
users: AccessSummaryUser[]
|
||||
servers: Server[]
|
||||
allSectionsCount: number
|
||||
}
|
||||
|
||||
function SettingsAccessSummaryDataGrid({
|
||||
users,
|
||||
servers,
|
||||
allSectionsCount,
|
||||
}: SettingsAccessSummaryDataGridProps) {
|
||||
const columns = useMemo<CompactDataGridColumn<AccessSummaryUser>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
header: "Пользователь",
|
||||
accessorKey: "name",
|
||||
cell: (u) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<AvatarCircle avatar={u.avatar} active={u.active} />
|
||||
<span className="text-sm font-medium">{u.name}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "sections",
|
||||
header: "Разделы",
|
||||
enableSorting: false,
|
||||
cell: (u) => {
|
||||
const writeSections =
|
||||
u.role === "admin"
|
||||
? []
|
||||
: u.sections.filter((s) => s.level === "write").map((s) => s.section)
|
||||
const readSections =
|
||||
u.role === "admin"
|
||||
? []
|
||||
: u.sections.filter((s) => s.level === "read").map((s) => s.section)
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{u.role === "admin" ? (
|
||||
<span className="text-violet-600 dark:text-violet-400 font-medium">
|
||||
Все ({allSectionsCount})
|
||||
</span>
|
||||
) : (
|
||||
<span>{readSections.length + writeSections.length} из {allSectionsCount}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "servers",
|
||||
header: "Серверы",
|
||||
enableSorting: false,
|
||||
cell: (u) => {
|
||||
const accessServers =
|
||||
u.role === "admin"
|
||||
? servers
|
||||
: servers.filter((s) => u.servers.find((p) => p.serverId === s.id && p.level !== "none"))
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{u.role === "admin" ? (
|
||||
<span className="text-violet-600 dark:text-violet-400 font-medium">
|
||||
Все ({servers.length})
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{accessServers.length} из {servers.length}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "write",
|
||||
header: "Права записи",
|
||||
enableSorting: false,
|
||||
cell: (u) => {
|
||||
const writeSections =
|
||||
u.role === "admin"
|
||||
? []
|
||||
: u.sections.filter((s) => s.level === "write").map((s) => s.section)
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{u.role === "admin" ? (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20">
|
||||
Полный доступ
|
||||
</span>
|
||||
) : writeSections.length === 0 ? (
|
||||
<span className="text-[10px] text-muted-foreground">Только просмотр</span>
|
||||
) : (
|
||||
<>
|
||||
{writeSections.slice(0, 3).map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20"
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
{writeSections.length > 3 && (
|
||||
<span className="text-[10px] text-muted-foreground">+{writeSections.length - 3}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[allSectionsCount, servers],
|
||||
)
|
||||
|
||||
return (
|
||||
<CompactDataGrid
|
||||
data={users}
|
||||
columns={columns}
|
||||
compact
|
||||
emptyTitle="Нет пользователей"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { SettingsAccessSummaryDataGrid, type SettingsAccessSummaryDataGridProps }
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { DataGridProps } from "@/components/reui/data-grid/data-grid"
|
||||
|
||||
const DATA_GRID_CELL_PAD = "py-3"
|
||||
const DATA_GRID_CELL_PAD_FIRST = "pl-5 py-3"
|
||||
const DATA_GRID_CELL_PAD_LAST = "pr-4 py-3"
|
||||
|
||||
const DEFAULT_DATA_GRID_LAYOUT: NonNullable<DataGridProps<object>["tableLayout"]> = {
|
||||
rowBorder: true,
|
||||
headerBackground: true,
|
||||
headerBorder: true,
|
||||
columnsResizable: false,
|
||||
}
|
||||
|
||||
const DEFAULT_DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<object>["tableClassNames"]> = {
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: "group/row",
|
||||
}
|
||||
|
||||
const DATA_GRID_CONTAINER_CLASS = "rounded-none border-0"
|
||||
|
||||
export {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
DEFAULT_DATA_GRID_LAYOUT,
|
||||
DEFAULT_DATA_GRID_CLASS_NAMES,
|
||||
DATA_GRID_CONTAINER_CLASS,
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import type { Table } from "@tanstack/react-table"
|
||||
import {
|
||||
DataGrid,
|
||||
DataGridContainer,
|
||||
DataGridPagination,
|
||||
DataGridTable,
|
||||
type DataGridProps,
|
||||
} from "@/components/reui/data-grid"
|
||||
import {
|
||||
DATA_GRID_CONTAINER_CLASS,
|
||||
DEFAULT_DATA_GRID_CLASS_NAMES,
|
||||
DEFAULT_DATA_GRID_LAYOUT,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
|
||||
interface DataGridShellProps<TData extends object> {
|
||||
table: Table<TData>
|
||||
recordCount: number
|
||||
children?: ReactNode
|
||||
pagination?: boolean
|
||||
isLoading?: boolean
|
||||
loadingMode?: DataGridProps<TData>["loadingMode"]
|
||||
emptyMessage?: ReactNode
|
||||
onRowClick?: DataGridProps<TData>["onRowClick"]
|
||||
tableLayout?: DataGridProps<TData>["tableLayout"]
|
||||
tableClassNames?: DataGridProps<TData>["tableClassNames"]
|
||||
}
|
||||
|
||||
function DataGridShell<TData extends object>({
|
||||
table,
|
||||
recordCount,
|
||||
children,
|
||||
pagination = false,
|
||||
isLoading,
|
||||
loadingMode,
|
||||
emptyMessage,
|
||||
onRowClick,
|
||||
tableLayout = DEFAULT_DATA_GRID_LAYOUT,
|
||||
tableClassNames = DEFAULT_DATA_GRID_CLASS_NAMES,
|
||||
}: DataGridShellProps<TData>) {
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={recordCount}
|
||||
isLoading={isLoading}
|
||||
loadingMode={loadingMode}
|
||||
emptyMessage={emptyMessage}
|
||||
onRowClick={onRowClick}
|
||||
tableLayout={tableLayout}
|
||||
tableClassNames={tableClassNames}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<DataGridContainer border={false} className={DATA_GRID_CONTAINER_CLASS}>
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
{pagination && recordCount > 0 && (
|
||||
<DataGridPagination className="px-5 pb-3" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridShell, type DataGridShellProps }
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client"
|
||||
|
||||
import type { Column } from "@tanstack/react-table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon } from "lucide-react"
|
||||
|
||||
interface DataGridSortHeaderProps<TData> {
|
||||
column: Column<TData, unknown>
|
||||
title: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function DataGridSortHeader<TData>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: DataGridSortHeaderProps<TData>) {
|
||||
const sorted = column.getIsSorted()
|
||||
const canSort = column.getCanSort()
|
||||
|
||||
if (!canSort) {
|
||||
return (
|
||||
<span className={cn("text-xs font-medium text-muted-foreground", className)}>
|
||||
{title}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground",
|
||||
"hover:text-foreground transition-colors rounded-md -ml-1 px-1 py-0.5",
|
||||
className,
|
||||
)}
|
||||
onClick={column.getToggleSortingHandler()}
|
||||
>
|
||||
{title}
|
||||
{sorted === "asc" ? (
|
||||
<ArrowUpIcon className="size-3 text-foreground" />
|
||||
) : sorted === "desc" ? (
|
||||
<ArrowDownIcon className="size-3 text-foreground" />
|
||||
) : (
|
||||
<ArrowUpDownIcon className="size-3 opacity-35" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridSortHeader, type DataGridSortHeaderProps }
|
||||
@@ -0,0 +1,498 @@
|
||||
"use client"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
CompactDataGrid,
|
||||
type CompactDataGridColumn,
|
||||
type CompactDataGridProps,
|
||||
} from "@/components/data-grids/compact-data-grid"
|
||||
import type {
|
||||
AlertEngineRuleDiagSnapshot,
|
||||
PingProbeSnapshot,
|
||||
ResourceServerSnapshot,
|
||||
ServerRestPingSnapshot,
|
||||
SpeedRunSnapshot,
|
||||
TrafficServerSnapshot,
|
||||
} from "@/lib/scheduler-run-snapshot"
|
||||
|
||||
type SnapshotRow = { id: string }
|
||||
|
||||
function withRowId<T extends { serverId?: number; probeId?: string; ruleId?: string; target?: string }>(
|
||||
rows: T[],
|
||||
idFn: (row: T, index: number) => string,
|
||||
): (T & SnapshotRow)[] {
|
||||
return rows.map((row, index) => ({ ...row, id: idFn(row, index) }))
|
||||
}
|
||||
|
||||
function SnapshotOkBadge({
|
||||
ok,
|
||||
okLabel = "ok",
|
||||
errLabel = "ошибка",
|
||||
}: {
|
||||
ok: boolean
|
||||
okLabel?: string
|
||||
errLabel?: string
|
||||
}) {
|
||||
return ok ? (
|
||||
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400">
|
||||
{okLabel}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive">
|
||||
{errLabel}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function SnapshotErrorCell({ error, className }: { error?: string; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn("text-destructive max-w-[220px] truncate block", className)}
|
||||
title={error}
|
||||
>
|
||||
{error ?? "—"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SnapshotDataGrid<T extends SnapshotRow>(
|
||||
props: Omit<CompactDataGridProps<T>, "compact">,
|
||||
) {
|
||||
return <CompactDataGrid {...props} compact />
|
||||
}
|
||||
|
||||
function TrafficSnapshotGrid({ servers }: { servers: TrafficServerSnapshot[] }) {
|
||||
const data = withRowId(servers, (s) => String(s.serverId))
|
||||
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
|
||||
{ id: "name", header: "Сервер", accessorKey: "name", cell: (r) => <span className="font-medium">{r.name}</span> },
|
||||
{
|
||||
id: "host",
|
||||
header: "Хост",
|
||||
accessorKey: "host",
|
||||
cell: (r) => <span className="font-mono text-muted-foreground">{r.host}</span>,
|
||||
},
|
||||
{
|
||||
id: "ok",
|
||||
header: "Результат",
|
||||
enableSorting: false,
|
||||
cell: (r) => <SnapshotOkBadge ok={r.ok} />,
|
||||
},
|
||||
{
|
||||
id: "interfaces",
|
||||
header: "IF",
|
||||
accessorKey: "interfaces",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => r.interfaces ?? "—",
|
||||
},
|
||||
{
|
||||
id: "sumRxMbps",
|
||||
header: "Σ RX",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.sumRxMbps != null ? `${r.sumRxMbps} Мбит/с` : "—"),
|
||||
},
|
||||
{
|
||||
id: "sumTxMbps",
|
||||
header: "Σ TX",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.sumTxMbps != null ? `${r.sumTxMbps} Мбит/с` : "—"),
|
||||
},
|
||||
{
|
||||
id: "error",
|
||||
header: "Ошибка",
|
||||
enableSorting: false,
|
||||
cell: (r) => <SnapshotErrorCell error={r.error} />,
|
||||
},
|
||||
]
|
||||
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет сэмплов трафика" />
|
||||
}
|
||||
|
||||
function ResourcesSnapshotGrid({ servers }: { servers: ResourceServerSnapshot[] }) {
|
||||
const data = withRowId(servers, (s) => String(s.serverId))
|
||||
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
|
||||
{
|
||||
id: "name",
|
||||
header: "Сервер",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<div>
|
||||
<span className="font-medium">{r.name}</span>
|
||||
<span className="block font-mono text-[10px] text-muted-foreground">{r.host}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Статус",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
r.status === "online" && "border-emerald-500/40 text-emerald-700 dark:text-emerald-400",
|
||||
r.status === "offline" && "border-destructive/50 text-destructive",
|
||||
)}
|
||||
>
|
||||
{r.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "cpuLoadPct",
|
||||
header: "CPU %",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => r.cpuLoadPct ?? "—",
|
||||
},
|
||||
{
|
||||
id: "memory",
|
||||
header: "Память",
|
||||
enableSorting: false,
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums whitespace-nowrap",
|
||||
cell: (r) =>
|
||||
r.memUsedMb != null && r.memTotalMb != null ? `${r.memUsedMb} / ${r.memTotalMb} МБ` : "—",
|
||||
},
|
||||
{
|
||||
id: "memUsedPct",
|
||||
header: "% RAM",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.memUsedPct != null ? `${r.memUsedPct}%` : "—"),
|
||||
},
|
||||
{
|
||||
id: "disk",
|
||||
header: "Диск своб.",
|
||||
enableSorting: false,
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums whitespace-nowrap",
|
||||
cell: (r) =>
|
||||
r.diskFreeMb != null && r.diskTotalMb != null ? `${r.diskFreeMb} / ${r.diskTotalMb} МБ` : "—",
|
||||
},
|
||||
{
|
||||
id: "uptimeSeconds",
|
||||
header: "Uptime",
|
||||
cellClassName: "tabular-nums",
|
||||
cell: (r) => (r.uptimeSeconds != null ? fmtUptimeSec(r.uptimeSeconds) : "—"),
|
||||
},
|
||||
{
|
||||
id: "board",
|
||||
header: "Плата / ROS",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<div className="max-w-[140px]">
|
||||
<span className="block truncate" title={r.boardName}>{r.boardName || "—"}</span>
|
||||
<span className="block truncate text-muted-foreground font-mono text-[10px]" title={r.rosVersion}>
|
||||
{r.rosVersion || ""}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "error",
|
||||
header: "Ошибка",
|
||||
enableSorting: false,
|
||||
cell: (r) => <SnapshotErrorCell error={r.error} className="max-w-[160px]" />,
|
||||
},
|
||||
]
|
||||
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет сэмплов ресурсов" />
|
||||
}
|
||||
|
||||
function ServersRestPingSnapshotGrid({ servers }: { servers: ServerRestPingSnapshot[] }) {
|
||||
const data = withRowId(servers, (s) => String(s.serverId))
|
||||
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
|
||||
{ id: "name", header: "Сервер", accessorKey: "name", cell: (r) => <span className="font-medium">{r.name}</span> },
|
||||
{
|
||||
id: "host",
|
||||
header: "Хост",
|
||||
accessorKey: "host",
|
||||
cell: (r) => <span className="font-mono text-muted-foreground">{r.host}</span>,
|
||||
},
|
||||
{
|
||||
id: "ok",
|
||||
header: "Результат",
|
||||
enableSorting: false,
|
||||
cell: (r) => <SnapshotOkBadge ok={r.ok} errLabel="недоступен" />,
|
||||
},
|
||||
{
|
||||
id: "latencyMs",
|
||||
header: "RTT REST",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.latencyMs != null ? `${r.latencyMs} мс` : "—"),
|
||||
},
|
||||
{
|
||||
id: "error",
|
||||
header: "Ошибка",
|
||||
enableSorting: false,
|
||||
cell: (r) => <SnapshotErrorCell error={r.error} />,
|
||||
},
|
||||
]
|
||||
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет сэмплов REST ping" />
|
||||
}
|
||||
|
||||
function PingSnapshotGrid({ probes }: { probes: PingProbeSnapshot[] }) {
|
||||
const data = withRowId(probes, (p) => `${p.probeId}-${p.target}`)
|
||||
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
|
||||
{
|
||||
id: "name",
|
||||
header: "Проба",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<div>
|
||||
<span className="font-medium">{r.name}</span>
|
||||
<span className="block font-mono text-[10px] text-muted-foreground">{r.probeId}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "target",
|
||||
header: "Цель",
|
||||
accessorKey: "target",
|
||||
cell: (r) => <span className="font-mono">{r.target}</span>,
|
||||
},
|
||||
{ id: "srcServerName", header: "Источник", accessorKey: "srcServerName" },
|
||||
{
|
||||
id: "srcInterface",
|
||||
header: "IF",
|
||||
accessorKey: "srcInterface",
|
||||
cell: (r) => <span className="font-mono text-muted-foreground">{r.srcInterface || "—"}</span>,
|
||||
},
|
||||
{
|
||||
id: "rttMs",
|
||||
header: "RTT",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.rttMs != null ? `${r.rttMs} мс` : "—"),
|
||||
},
|
||||
{
|
||||
id: "lossPct",
|
||||
header: "Loss",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => `${r.lossPct}%`,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Статус",
|
||||
enableSorting: false,
|
||||
cell: (r) => <Badge variant="outline" className="text-[10px]">{r.status}</Badge>,
|
||||
},
|
||||
{
|
||||
id: "error",
|
||||
header: "Ошибка",
|
||||
enableSorting: false,
|
||||
cell: (r) => <SnapshotErrorCell error={r.error} className="max-w-[180px]" />,
|
||||
},
|
||||
]
|
||||
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет сэмплов ping" />
|
||||
}
|
||||
|
||||
function SpeedSnapshotGrid({ runs }: { runs: SpeedRunSnapshot[] }) {
|
||||
const data = withRowId(runs, (r) => r.probeId)
|
||||
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
|
||||
{
|
||||
id: "probeId",
|
||||
header: "Проба",
|
||||
accessorKey: "probeId",
|
||||
cell: (r) => <span className="font-mono">{r.probeId}</span>,
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: "Маршрут",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<span className="whitespace-nowrap">
|
||||
{r.srcServerName} <span className="text-muted-foreground">→</span> {r.dstServerName}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "interfaces",
|
||||
header: "Интерфейсы",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<span className="font-mono text-[10px]">
|
||||
<span className="block">{r.srcInterface || "—"}</span>
|
||||
<span className="block text-muted-foreground">{r.dstInterface || "—"}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "protocol",
|
||||
header: "Протокол",
|
||||
enableSorting: false,
|
||||
cell: (r) => `${r.protocol} / ${r.direction} / ${r.durationSec}s`,
|
||||
},
|
||||
{
|
||||
id: "txAvgMbps",
|
||||
header: "TX",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.txAvgMbps != null ? `${Number(r.txAvgMbps).toFixed(1)}` : "—"),
|
||||
},
|
||||
{
|
||||
id: "rxAvgMbps",
|
||||
header: "RX",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.rxAvgMbps != null ? `${Number(r.rxAvgMbps).toFixed(1)}` : "—"),
|
||||
},
|
||||
{
|
||||
id: "pingRttMs",
|
||||
header: "Ping RTT",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.pingRttMs != null ? `${r.pingRttMs} мс` : "—"),
|
||||
},
|
||||
{
|
||||
id: "pingLossPct",
|
||||
header: "Loss",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.pingLossPct != null ? `${r.pingLossPct}%` : "—"),
|
||||
},
|
||||
{
|
||||
id: "ok",
|
||||
header: "Результат",
|
||||
enableSorting: false,
|
||||
cell: (r) => <SnapshotOkBadge ok={r.ok} />,
|
||||
},
|
||||
{
|
||||
id: "error",
|
||||
header: "Ошибка",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<div className="max-w-[200px]">
|
||||
<span className="text-destructive block truncate" title={r.error}>{r.error ?? ""}</span>
|
||||
{r.pingError ? (
|
||||
<span className="text-[10px] text-amber-600 dark:text-amber-400 block truncate" title={r.pingError ?? ""}>
|
||||
ping: {r.pingError}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет прогонов speed" />
|
||||
}
|
||||
|
||||
function transitionRu(t: AlertEngineRuleDiagSnapshot["hitTransition"]): string {
|
||||
switch (t) {
|
||||
case "problem":
|
||||
return "проблема"
|
||||
case "recovery":
|
||||
return "восстановление"
|
||||
case "neutral":
|
||||
return "нейтрально"
|
||||
default:
|
||||
return "—"
|
||||
}
|
||||
}
|
||||
|
||||
function blockedRu(b: AlertEngineRuleDiagSnapshot["blocked"]): string {
|
||||
switch (b) {
|
||||
case "no_hit":
|
||||
return "условие не выполнено"
|
||||
case "stability":
|
||||
return "стабильность (confirmStabilitySec)"
|
||||
case "cooldown":
|
||||
return "cooldown"
|
||||
case "no_telegram":
|
||||
return "нет Telegram"
|
||||
case "dedupe_positive":
|
||||
return "дедуп восстановления"
|
||||
case "in_group":
|
||||
return "в группе (отдельно не шлём)"
|
||||
default:
|
||||
return "—"
|
||||
}
|
||||
}
|
||||
|
||||
function AlertEngineRuleDiagGrid({ ruleDiag }: { ruleDiag: AlertEngineRuleDiagSnapshot[] }) {
|
||||
const data = withRowId(ruleDiag, (d) => d.ruleId)
|
||||
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
|
||||
{
|
||||
id: "ruleId",
|
||||
header: "ID правила",
|
||||
accessorKey: "ruleId",
|
||||
cell: (r) => (
|
||||
<span className="font-mono max-w-[140px] truncate block" title={r.ruleId}>
|
||||
{r.ruleId}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "evalHit",
|
||||
header: "Сработало",
|
||||
cell: (r) => (r.evalHit ? "Да" : "Нет"),
|
||||
},
|
||||
{
|
||||
id: "hitTransition",
|
||||
header: "Тип срабатывания",
|
||||
cell: (r) => transitionRu(r.hitTransition),
|
||||
},
|
||||
{
|
||||
id: "stabilityOk",
|
||||
header: "Стабильность",
|
||||
cell: (r) => (r.stabilityOk ? "Да" : "Нет"),
|
||||
},
|
||||
{
|
||||
id: "cooldownOk",
|
||||
header: "Кулдаун",
|
||||
cell: (r) => (r.cooldownOk ? "Да" : "Нет"),
|
||||
},
|
||||
{
|
||||
id: "telegramOk",
|
||||
header: "Telegram",
|
||||
cell: (r) => (r.telegramOk ? "Да" : "Нет"),
|
||||
},
|
||||
{
|
||||
id: "hitMessage",
|
||||
header: "Сообщение",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<span className="font-mono max-w-[280px] truncate text-muted-foreground block" title={r.hitMessage ?? ""}>
|
||||
{r.hitMessage ?? "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "blocked",
|
||||
header: "Причина блока",
|
||||
cell: (r) => <span className="text-muted-foreground">{blockedRu(r.blocked)}</span>,
|
||||
},
|
||||
]
|
||||
return (
|
||||
<SnapshotDataGrid
|
||||
data={data}
|
||||
columns={columns}
|
||||
emptyTitle="Нет диагностики по правилам"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtUptimeSec(sec: number): string {
|
||||
if (sec <= 0) return "—"
|
||||
const d = Math.floor(sec / 86400)
|
||||
const h = Math.floor((sec % 86400) / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
if (d > 0) return `${d}д ${h}ч`
|
||||
if (h > 0) return `${h}ч ${m}м`
|
||||
return `${m}м`
|
||||
}
|
||||
|
||||
export {
|
||||
SnapshotDataGrid,
|
||||
TrafficSnapshotGrid,
|
||||
ResourcesSnapshotGrid,
|
||||
ServersRestPingSnapshotGrid,
|
||||
PingSnapshotGrid,
|
||||
SpeedSnapshotGrid,
|
||||
AlertEngineRuleDiagGrid,
|
||||
type SnapshotRow,
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Server } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { CableIcon, CopyIcon, EyeIcon, EyeOffIcon, Trash2Icon } from "lucide-react"
|
||||
|
||||
export interface SubUserRow {
|
||||
id: string
|
||||
login: string
|
||||
password: string
|
||||
description: string
|
||||
jhServerIds: string[]
|
||||
clientIp: string
|
||||
active: boolean
|
||||
lastSeen: string | null
|
||||
}
|
||||
|
||||
interface SubusersDataGridProps {
|
||||
subUsers: SubUserRow[]
|
||||
servers: Server[]
|
||||
revealedIds: Set<string>
|
||||
onToggleReveal: (id: string) => void
|
||||
onToggleActive: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
function SubusersDataGrid({
|
||||
subUsers,
|
||||
servers,
|
||||
revealedIds,
|
||||
onToggleReveal,
|
||||
onToggleActive,
|
||||
onRemove,
|
||||
}: SubusersDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<SubUserRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "login",
|
||||
accessorKey: "login",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Логин / описание" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const su = row.original
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-mono font-medium truncate">{su.login}</p>
|
||||
{su.description && (
|
||||
<p className="text-[11px] text-muted-foreground truncate">{su.description}</p>
|
||||
)}
|
||||
{su.lastSeen && (
|
||||
<p className="text-[10px] text-muted-foreground/50">{su.lastSeen}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Логин / описание",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "password",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Пароль</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const su = row.original
|
||||
const revealed = revealedIds.has(su.id)
|
||||
return (
|
||||
<div className="flex items-center gap-1 min-w-0" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="font-mono text-[11px] truncate flex-1">
|
||||
{revealed ? su.password : "••••••••••••"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleReveal(su.id)}
|
||||
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors"
|
||||
>
|
||||
{revealed ? <EyeOffIcon className="size-3" /> : <EyeIcon className="size-3" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard.writeText(su.password).catch(() => {})}
|
||||
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors"
|
||||
>
|
||||
<CopyIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Пароль",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "jhServers",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">JH-серверы</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const jhs = servers.filter((s) => row.original.jhServerIds.includes(s.id))
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 min-w-0">
|
||||
{jhs.length === 0 ? (
|
||||
<span className="text-[11px] text-muted-foreground/40">—</span>
|
||||
) : (
|
||||
jhs.map((jh) => (
|
||||
<span
|
||||
key={jh.id}
|
||||
className="inline-flex items-center gap-1 text-[10px] font-medium bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20 rounded px-1 py-0.5"
|
||||
>
|
||||
<Flag code={jh.country} size={10} />
|
||||
{jh.name.split("-").slice(-1)[0]}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "JH-серверы",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clientIp",
|
||||
accessorKey: "clientIp",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="IP-клиента" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-[11px] text-muted-foreground truncate">
|
||||
{row.original.clientIp || "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "IP-клиента",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "active",
|
||||
accessorKey: "active",
|
||||
header: () => <span className="sr-only">Активен</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<FormToggle
|
||||
checked={row.original.active}
|
||||
onChange={() => onToggleActive(row.original.id)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
size: 48,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Удалить</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(row.original.id)}
|
||||
className="text-muted-foreground/50 hover:text-destructive transition-colors opacity-0 group-hover/row:opacity-100 focus-visible:opacity-100"
|
||||
aria-label="Удалить GRE-клиента"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</button>
|
||||
),
|
||||
size: 40,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[onRemove, onToggleActive, onToggleReveal, revealedIds, servers],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: subUsers,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (subUsers.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<CableIcon className="size-4" />}
|
||||
title="Нет GRE-клиентов"
|
||||
description="Добавьте учётки для подключения устройств"
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={subUsers.length}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { SubusersDataGrid, type SubusersDataGridProps }
|
||||
@@ -0,0 +1,385 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Server } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { Sparkline } from "@/components/sparkline"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
ClockIcon,
|
||||
CpuIcon,
|
||||
HardDriveIcon,
|
||||
ServerIcon,
|
||||
SearchIcon,
|
||||
ThermometerIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export interface UptimeResourceRow {
|
||||
serverId: string
|
||||
server: Server
|
||||
hasData: boolean
|
||||
cpu: number
|
||||
cpuHistory: number[]
|
||||
ramUsed: number
|
||||
ramTotal: number
|
||||
ramPct: number
|
||||
hddUsed: number
|
||||
hddTotal: number
|
||||
hddPct: number
|
||||
uptimeSeconds: number
|
||||
boardName: string
|
||||
temp?: number
|
||||
}
|
||||
|
||||
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
|
||||
return (
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
|
||||
type === "home-router"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: type === "jump-host"
|
||||
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
|
||||
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
)}>
|
||||
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtMB(mb: number): string {
|
||||
if (mb >= 1024) return `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ`
|
||||
return `${mb.toFixed(1)} МБ`
|
||||
}
|
||||
|
||||
function fmtUptime(sec: number): string {
|
||||
const d = Math.floor(sec / 86400)
|
||||
const h = Math.floor((sec % 86400) / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
if (d > 0) return `${d}д ${h}ч`
|
||||
if (h > 0) return `${h}ч ${m}м`
|
||||
return `${m}м`
|
||||
}
|
||||
|
||||
function resPctColor(pct: number, warn = 70, crit = 85): string {
|
||||
if (pct >= crit) return "text-red-600 dark:text-red-400"
|
||||
if (pct >= warn) return "text-amber-600 dark:text-amber-400"
|
||||
return "text-emerald-600 dark:text-emerald-400"
|
||||
}
|
||||
|
||||
function resBarColor(pct: number, warn = 70, crit = 85): string {
|
||||
if (pct >= crit) return "bg-red-500"
|
||||
if (pct >= warn) return "bg-amber-500"
|
||||
return "bg-emerald-500"
|
||||
}
|
||||
|
||||
function MiniBar({
|
||||
pct,
|
||||
warn = 70,
|
||||
crit = 85,
|
||||
className,
|
||||
}: {
|
||||
pct: number
|
||||
warn?: number
|
||||
crit?: number
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("h-1.5 rounded-full bg-muted overflow-hidden", className)}>
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-700", resBarColor(pct, warn, crit))}
|
||||
style={{ width: `${Math.min(100, Math.max(0, pct))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface UptimeResourcesDataGridProps {
|
||||
rows: UptimeResourceRow[]
|
||||
}
|
||||
|
||||
function UptimeResourcesDataGrid({ rows }: UptimeResourcesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<UptimeResourceRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
accessorFn: (row) => row.server.name,
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Сервер" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const srv = r.server
|
||||
const offline = srv.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
const isCrit =
|
||||
!noMetrics &&
|
||||
(r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isCrit && <AlertCircleIcon className="size-3.5 text-red-500 shrink-0" />}
|
||||
{!isCrit && <StatusDot status={srv.status} pulse={!offline} />}
|
||||
<Flag code={srv.country} size={16} />
|
||||
<span className="font-mono font-semibold">{srv.name}</span>
|
||||
<TypeChip type={srv.type} />
|
||||
<span className="text-xs text-muted-foreground hidden xl:inline">{srv.site}</span>
|
||||
{!offline && !r.hasData && (
|
||||
<span className="text-[10px] rounded border border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400 px-1.5 py-0.5">
|
||||
нет данных
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "board",
|
||||
accessorKey: "boardName",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground hidden md:inline">
|
||||
Модель · ROS
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<div className="hidden md:flex flex-col leading-tight">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{r.hasData ? r.boardName : "—"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">{r.server.os}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: "hidden md:table-cell", cellClassName: "hidden md:table-cell px-4 py-3" },
|
||||
},
|
||||
{
|
||||
id: "cpu",
|
||||
accessorKey: "cpu",
|
||||
header: ({ column }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CpuIcon className="size-3.5 text-muted-foreground" />
|
||||
<DataGridSortHeader column={column} title="CPU" />
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const offline = r.server.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
if (noMetrics) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground/30">
|
||||
{offline ? "—" : "нет опроса"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const cpuColor =
|
||||
r.cpu >= 85 ? "hsl(0 84% 60%)" : r.cpu >= 70 ? "hsl(38 92% 50%)" : "hsl(142 76% 36%)"
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 min-w-[140px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("font-mono text-sm font-semibold tabular-nums w-10 shrink-0", resPctColor(r.cpu))}>
|
||||
{r.cpu}%
|
||||
</span>
|
||||
<MiniBar pct={r.cpu} className="flex-1" />
|
||||
</div>
|
||||
<Sparkline data={r.cpuHistory} width={120} height={18} color={cpuColor} filled />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "CPU",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "min-w-[160px]"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "min-w-[160px]"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ram",
|
||||
accessorKey: "ramPct",
|
||||
header: ({ column }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<HardDriveIcon className="size-3.5 text-muted-foreground" />
|
||||
<DataGridSortHeader column={column} title="RAM" />
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const offline = r.server.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
if (noMetrics) {
|
||||
return <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 min-w-[155px]">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className={cn("font-mono font-semibold", resPctColor(r.ramPct))}>{r.ramPct}%</span>
|
||||
<span className="text-muted-foreground/60 font-mono text-[10px]">
|
||||
{fmtMB(r.ramUsed)}/{fmtMB(r.ramTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<MiniBar pct={r.ramPct} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "RAM",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "min-w-[175px]"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "min-w-[175px]"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "hdd",
|
||||
accessorKey: "hddPct",
|
||||
header: ({ column }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<HardDriveIcon className="size-3.5 text-muted-foreground" />
|
||||
<DataGridSortHeader column={column} title="Диск" />
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const offline = r.server.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
if (noMetrics) {
|
||||
return <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 min-w-[155px]">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className={cn("font-mono font-semibold", resPctColor(r.hddPct))}>{r.hddPct}%</span>
|
||||
<span className="text-muted-foreground/60 font-mono text-[10px]">
|
||||
{fmtMB(r.hddUsed)}/{fmtMB(r.hddTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<MiniBar pct={r.hddPct} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Диск",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "min-w-[175px]"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "min-w-[175px]"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
accessorKey: "uptimeSeconds",
|
||||
header: ({ column }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ClockIcon className="size-3.5 text-muted-foreground" />
|
||||
<DataGridSortHeader column={column} title="Uptime" />
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const offline = r.server.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
return (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{noMetrics ? "—" : fmtUptime(r.uptimeSeconds)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Uptime",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "temp",
|
||||
accessorKey: "temp",
|
||||
header: ({ column }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ThermometerIcon className="size-3.5 text-muted-foreground" />
|
||||
<DataGridSortHeader column={column} title="°C" />
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const offline = r.server.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
if (r.temp !== undefined && !noMetrics) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-sm font-semibold tabular-nums",
|
||||
r.temp >= 70
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: r.temp >= 55
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{r.temp}°C
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <span className="text-muted-foreground/30 text-xs">—</span>
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "°C",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => row.serverId,
|
||||
})
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<SearchIcon className="size-4" />}
|
||||
title="Ничего не найдено"
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rows.length}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border bg-muted/30",
|
||||
bodyRow: cn(
|
||||
"group/row hover:bg-muted/30",
|
||||
"[&:has([data-resource-offline=true])]:opacity-50",
|
||||
"[&:has([data-resource-crit=true])]:bg-red-500/3",
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { UptimeResourcesDataGrid, type UptimeResourcesDataGridProps }
|
||||
@@ -0,0 +1,242 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Server } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ArrowRightIcon, RefreshCwIcon } from "lucide-react"
|
||||
|
||||
export interface SpeedTestRunRow {
|
||||
id: string
|
||||
startedAt: number
|
||||
srcServerId: string
|
||||
dstServerId: string
|
||||
srcInterface?: string
|
||||
dstInterface?: string
|
||||
protocol: "tcp" | "udp"
|
||||
direction: "transmit" | "receive" | "both"
|
||||
durationSec: number
|
||||
txAvgMbps: number
|
||||
rxAvgMbps: number
|
||||
status: "running" | "done" | "error"
|
||||
afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null
|
||||
srcInterfaceAddress?: string | null
|
||||
dstInterfaceAddress?: string | null
|
||||
}
|
||||
|
||||
interface UptimeSpeedHistoryDataGridProps {
|
||||
runs: SpeedTestRunRow[]
|
||||
servers: Server[]
|
||||
}
|
||||
|
||||
function UptimeSpeedHistoryDataGrid({ runs, servers }: UptimeSpeedHistoryDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<SpeedTestRunRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "startedAt",
|
||||
accessorKey: "startedAt",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Время" className="ml-1" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground whitespace-nowrap tabular-nums font-mono text-xs">
|
||||
{new Date(row.original.startedAt).toLocaleString("ru-RU", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Время",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
accessorFn: (row) => `${row.srcServerId}-${row.dstServerId}`,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Маршрут" />,
|
||||
cell: ({ row }) => {
|
||||
const run = row.original
|
||||
const src = servers.find((s) => s.id === run.srcServerId)
|
||||
const dst = servers.find((s) => s.id === run.dstServerId)
|
||||
return (
|
||||
<div className="font-mono whitespace-nowrap text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Flag code={src?.country ?? "UN"} size={13} />
|
||||
<span>{src?.name ?? run.srcServerId}</span>
|
||||
<ArrowRightIcon className="size-3 text-muted-foreground" />
|
||||
<Flag code={dst?.country ?? "UN"} size={13} />
|
||||
<span>{dst?.name ?? run.dstServerId}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
|
||||
{run.srcInterfaceAddress && run.dstInterfaceAddress
|
||||
? `${run.srcInterfaceAddress} → ${run.dstInterfaceAddress}`
|
||||
: "внутренние IP: auto/не указаны"}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "Маршрут", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "params",
|
||||
accessorFn: (row) => `${row.protocol}-${row.direction}-${row.durationSec}`,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Параметры" />,
|
||||
cell: ({ row }) => {
|
||||
const run = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-muted-foreground whitespace-nowrap text-xs">
|
||||
<span className="inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold bg-muted/60 border-border/60">
|
||||
{run.protocol.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span>{run.direction}</span>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span>{run.durationSec}s</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "Параметры", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status
|
||||
if (status === "running") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[var(--status-degraded-fg)] text-xs">
|
||||
<RefreshCwIcon className="size-3 animate-spin" />
|
||||
running
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (status === "error") {
|
||||
return <span className="text-[var(--status-offline-fg)] text-xs">error</span>
|
||||
}
|
||||
return <span className="text-[var(--status-online-fg)] text-xs">done</span>
|
||||
},
|
||||
meta: { headerTitle: "Статус", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "txAvgMbps",
|
||||
accessorKey: "txAvgMbps",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="TX avg" />,
|
||||
cell: ({ row }) => {
|
||||
const run = row.original
|
||||
const maxVal = Math.max(run.txAvgMbps, run.rxAvgMbps, 1)
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-[120px]">
|
||||
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--chart-tx)]"
|
||||
style={{ width: `${(run.txAvgMbps / maxVal) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono tabular-nums text-[var(--chart-tx)] font-medium whitespace-nowrap text-xs">
|
||||
{run.txAvgMbps} Мбит/с
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "TX avg", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "rxAvgMbps",
|
||||
accessorKey: "rxAvgMbps",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="RX avg" />,
|
||||
cell: ({ row }) => {
|
||||
const run = row.original
|
||||
const maxVal = Math.max(run.txAvgMbps, run.rxAvgMbps, 1)
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-[120px]">
|
||||
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--chart-rx)]"
|
||||
style={{ width: `${(run.rxAvgMbps / maxVal) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono tabular-nums text-[var(--chart-rx)] font-medium whitespace-nowrap text-xs">
|
||||
{run.rxAvgMbps} Мбит/с
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "RX avg", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "afterBtPing",
|
||||
accessorFn: (row) => row.afterBtPing?.rttMs ?? -1,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Ping после BT" />,
|
||||
cell: ({ row }) => {
|
||||
const run = row.original
|
||||
if (run.status !== "done") return <span className="text-xs">—</span>
|
||||
if (run.afterBtPing?.error) {
|
||||
return (
|
||||
<span className="text-[var(--status-offline-fg)] text-xs font-mono" title={run.afterBtPing.error}>
|
||||
ошибка
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (run.afterBtPing?.rttMs != null) {
|
||||
return (
|
||||
<span className="font-mono tabular-nums whitespace-nowrap text-xs text-violet-600 dark:text-violet-400">
|
||||
{run.afterBtPing.rttMs} мс
|
||||
{run.afterBtPing.lossPct != null && run.afterBtPing.lossPct > 0 && (
|
||||
<span className="text-amber-600 dark:text-amber-400"> · {run.afterBtPing.lossPct}%</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="font-mono tabular-nums whitespace-nowrap text-xs text-amber-600 dark:text-amber-400">
|
||||
timeout
|
||||
{run.afterBtPing?.lossPct != null && <span> · {run.afterBtPing.lossPct}%</span>}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Ping после BT",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[servers],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: runs,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
initialState: { sorting: [{ id: "startedAt", desc: true }] },
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={runs.length}
|
||||
tableClassNames={{ bodyRow: cn("group/row text-xs") }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { UptimeSpeedHistoryDataGrid, type UptimeSpeedHistoryDataGridProps }
|
||||
@@ -0,0 +1,289 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Server, VxlanTunnel } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
CodeXmlIcon,
|
||||
MoreHorizontalIcon,
|
||||
NetworkIcon,
|
||||
PencilIcon,
|
||||
PowerIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
interface VxlanDataGridProps {
|
||||
tunnels: VxlanTunnel[]
|
||||
servers: Server[]
|
||||
onExport: (tunnel: VxlanTunnel) => void
|
||||
}
|
||||
|
||||
function VxlanDataGrid({ tunnels, servers, onExport }: VxlanDataGridProps) {
|
||||
const serverMap = useMemo(
|
||||
() => new Map(servers.map((s) => [s.id, s])),
|
||||
[servers],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<VxlanTunnel>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Имя / VTEP" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const t = row.original
|
||||
return (
|
||||
<div className="min-w-0 flex items-start gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 rounded-full shrink-0 mt-1.5",
|
||||
t.status === "up" ? "bg-emerald-500" : "bg-red-500",
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono font-medium text-sm truncate">{t.name}</p>
|
||||
<p className="text-[11px] text-muted-foreground font-mono">VTEP: {t.vtepIp}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Имя / VTEP",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "server",
|
||||
accessorKey: "serverId",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => {
|
||||
const srv = serverMap.get(row.original.serverId)
|
||||
if (!srv) return <span className="text-muted-foreground">—</span>
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
|
||||
<Flag code={srv.country} size={12} />
|
||||
<span className="font-mono truncate">{srv.name}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "vni",
|
||||
accessorKey: "vni",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="VNI" />,
|
||||
cell: ({ row }) => <span className="font-mono text-sm">{row.original.vni}</span>,
|
||||
meta: {
|
||||
headerTitle: "VNI",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dstPort",
|
||||
accessorKey: "dstPort",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Port" />,
|
||||
cell: ({ row }) => <span className="font-mono text-sm">{row.original.dstPort}</span>,
|
||||
meta: {
|
||||
headerTitle: "Port",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "remoteVteps",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground">Remote</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm text-center block">{row.original.remoteVteps.length}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Remote",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "arpProxy",
|
||||
accessorKey: "arpProxy",
|
||||
header: () => <span className="sr-only">ARP</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-mono px-1.5 py-0.5 rounded",
|
||||
row.original.arpProxy
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
ARP {row.original.arpProxy ? "✓" : "✗"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "macLearning",
|
||||
accessorKey: "macLearning",
|
||||
header: () => <span className="sr-only">MAC</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-mono px-1.5 py-0.5 rounded",
|
||||
row.original.macLearning
|
||||
? "bg-sky-500/10 text-sky-600 dark:text-sky-400"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
MAC {row.original.macLearning ? "✓" : "✗"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap",
|
||||
row.original.status === "up"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||
)}
|
||||
>
|
||||
{row.original.status === "up" ? "UP" : "DOWN"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => {
|
||||
const tunnel = row.original
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||
"opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100",
|
||||
"data-popup-open:opacity-100",
|
||||
)}
|
||||
aria-label={`Действия: ${tunnel.name}`}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem onClick={() => onExport(tunnel)}>
|
||||
<CodeXmlIcon className="size-4" />
|
||||
Экспорт .rsc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<PencilIcon className="size-4" />
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<PowerIcon className="size-4" />
|
||||
{tunnel.enabled ? "Отключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<Trash2Icon className="size-4" />
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
size: 56,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[onExport, serverMap],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: tunnels,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (tunnels.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title="Нет VXLAN-туннелей"
|
||||
description="Добавьте туннель или измените поиск"
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={tunnels.length}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { VxlanDataGrid, type VxlanDataGridProps }
|
||||
@@ -0,0 +1,276 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { WireGuardInterface } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { WireGuardPeersDetail } from "@/components/data-grids/wireguard-peers-detail"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CodeXmlIcon,
|
||||
MoreHorizontalIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
PowerIcon,
|
||||
ShieldCheckIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
export interface WgIfaceWithServer extends WireGuardInterface {
|
||||
serverId: string
|
||||
serverName: string
|
||||
serverCountry: string
|
||||
}
|
||||
|
||||
interface WireguardDataGridProps {
|
||||
interfaces: WgIfaceWithServer[]
|
||||
onExport: (iface: WgIfaceWithServer) => void
|
||||
}
|
||||
|
||||
function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<WgIfaceWithServer>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Интерфейс / Сервер" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const iface = row.original
|
||||
const expanded = row.getIsExpanded()
|
||||
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
{expanded ? (
|
||||
<ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 rounded-full shrink-0",
|
||||
iface.status === "up" ? "bg-emerald-500 animate-pulse" : "bg-red-500",
|
||||
)}
|
||||
/>
|
||||
<span className="font-mono font-semibold text-sm">{iface.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
|
||||
<Flag code={iface.serverCountry} size={12} />
|
||||
{iface.serverName}
|
||||
</div>
|
||||
<p className="sr-only">
|
||||
{onlinePeers}/{iface.peers.length} пиров
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Интерфейс / Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
expandedContent: (row: WgIfaceWithServer) => (
|
||||
<WireGuardPeersDetail peers={row.peers} />
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "listenPort",
|
||||
accessorKey: "listenPort",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Порт" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm">{row.original.listenPort}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Порт",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mtu",
|
||||
accessorKey: "mtu",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="MTU" />,
|
||||
cell: ({ row }) => <span className="font-mono text-sm">{row.original.mtu}</span>,
|
||||
meta: {
|
||||
headerTitle: "MTU",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "peers",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground">Пиры</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const iface = row.original
|
||||
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
|
||||
return (
|
||||
<span className="font-mono text-sm text-center block">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">{onlinePeers}</span>
|
||||
<span className="text-muted-foreground">/{iface.peers.length}</span>
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Пиры",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-mono px-2 py-0.5 rounded border",
|
||||
row.original.status === "up"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||
)}
|
||||
>
|
||||
{row.original.status === "up" ? "UP" : "DOWN"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => {
|
||||
const iface = row.original
|
||||
return (
|
||||
<div
|
||||
className="flex justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||
"opacity-0 transition-[opacity,background-color,color,border-color]",
|
||||
"group-hover/row:opacity-100 focus-visible:opacity-100",
|
||||
"data-popup-open:opacity-100 data-popup-open:bg-muted",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Действия: ${iface.name}`}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem onClick={() => onExport(iface)}>
|
||||
<CodeXmlIcon className="size-4" />
|
||||
Экспорт .rsc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<PencilIcon className="size-4" />
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<PlusIcon className="size-4" />
|
||||
Добавить пира
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<PowerIcon className="size-4" />
|
||||
{iface.enabled ? "Отключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<Trash2Icon className="size-4" />
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
size: 56,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[onExport],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: interfaces,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
getRowCanExpand: () => true,
|
||||
})
|
||||
|
||||
if (interfaces.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ShieldCheckIcon className="size-4" />}
|
||||
title="Нет WireGuard интерфейсов"
|
||||
description="Добавьте первый интерфейс или проверьте поиск"
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={interfaces.length}
|
||||
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { WireguardDataGrid, type WireguardDataGridProps }
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import type { WireGuardPeer } from "@/lib/data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
KeyRoundIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
function fmtBytes(n: number | undefined): string {
|
||||
if (!n) return "—"
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)} ГБ`
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)} КБ`
|
||||
return `${n} Б`
|
||||
}
|
||||
|
||||
function truncKey(key: string): string {
|
||||
if (key.length <= 20) return key
|
||||
return `${key.slice(0, 8)}…${key.slice(-8)}`
|
||||
}
|
||||
|
||||
function WireGuardPeersDetail({ peers }: { peers: WireGuardPeer[] }) {
|
||||
if (peers.length === 0) {
|
||||
return (
|
||||
<div className="px-5 py-4 text-xs text-muted-foreground text-center border-t border-border/50">
|
||||
Нет пиров
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-border/50">
|
||||
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-5 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
<span>Public Key</span>
|
||||
<span>Allowed IPs</span>
|
||||
<span>Последнее рукопожатие</span>
|
||||
<span>RX / TX</span>
|
||||
<span>Endpoint</span>
|
||||
</div>
|
||||
{peers.map((peer) => (
|
||||
<div
|
||||
key={peer.publicKey}
|
||||
className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-5 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
|
||||
{truncKey(peer.publicKey)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-muted-foreground truncate">
|
||||
{peer.allowedIps.join(", ")}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-[11px] whitespace-nowrap",
|
||||
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{peer.latestHandshake ?? "нет рукопожатия"}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<ArrowDownIcon className="size-3 text-emerald-500" />
|
||||
{fmtBytes(peer.transferRx)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<ArrowUpIcon className="size-3 text-blue-400" />
|
||||
{fmtBytes(peer.transferTx)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { WireGuardPeersDetail, fmtBytes, truncKey }
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface DataPageCardProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function DataPageCard({ children, className }: DataPageCardProps) {
|
||||
return (
|
||||
<Card className={cn("overflow-hidden py-0 gap-0", className)}>
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataPageCard, type DataPageCardProps }
|
||||
@@ -3,7 +3,11 @@
|
||||
import { ReactNode } from "react"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group"
|
||||
import {
|
||||
Filters,
|
||||
type Filter,
|
||||
@@ -58,15 +62,16 @@ function DataPageToolbar<T extends string = string>({
|
||||
/>
|
||||
)}
|
||||
{onSearchChange != null && (
|
||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
|
||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
className="h-6 border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
|
||||
<InputGroup className="min-w-[220px] max-w-sm">
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-3.5" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={search ?? ""}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</InputGroup>
|
||||
)}
|
||||
{countLabel && (
|
||||
<span className="text-sm text-muted-foreground ml-auto">{countLabel}</span>
|
||||
|
||||
+44
-29
@@ -4,18 +4,18 @@ import { useMemo, useState } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DataGrid,
|
||||
DataGridContainer,
|
||||
DataGridPagination,
|
||||
DataGridTable,
|
||||
} from "@/components/reui/data-grid"
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { InboxIcon } from "lucide-react"
|
||||
@@ -24,6 +24,7 @@ export interface Column<T> {
|
||||
key: string
|
||||
label: string
|
||||
render: (row: T) => React.ReactNode
|
||||
enableSorting?: boolean
|
||||
}
|
||||
|
||||
interface DataTableProps<T extends { id: string }> {
|
||||
@@ -34,6 +35,8 @@ interface DataTableProps<T extends { id: string }> {
|
||||
isLoading?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
pagination?: boolean
|
||||
countLabel?: string
|
||||
}
|
||||
|
||||
export function DataTable<T extends { id: string }>({
|
||||
@@ -44,9 +47,10 @@ export function DataTable<T extends { id: string }>({
|
||||
isLoading = false,
|
||||
emptyTitle = "Нет записей",
|
||||
emptyDescription,
|
||||
pagination = false,
|
||||
countLabel,
|
||||
}: DataTableProps<T>) {
|
||||
const [search, setSearch] = useState("")
|
||||
const [globalFilter, setGlobalFilter] = useState("")
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!search || searchKeys.length === 0) return data
|
||||
@@ -57,59 +61,70 @@ export function DataTable<T extends { id: string }>({
|
||||
}, [data, search, searchKeys])
|
||||
|
||||
const columnDefs = useMemo<ColumnDef<T>[]>(
|
||||
() =>
|
||||
columns.map((col) => ({
|
||||
() => {
|
||||
const defs: ColumnDef<T>[] = columns.map((col, index) => ({
|
||||
id: col.key,
|
||||
accessorKey: col.key,
|
||||
header: col.label,
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title={col.label} />
|
||||
),
|
||||
cell: ({ row }) => col.render(row.original),
|
||||
meta: { headerTitle: col.label },
|
||||
})),
|
||||
enableSorting: col.enableSorting !== false,
|
||||
meta: {
|
||||
headerTitle: col.label,
|
||||
headerClassName:
|
||||
index === 0
|
||||
? DATA_GRID_CELL_PAD_FIRST
|
||||
: index === columns.length - 1
|
||||
? DATA_GRID_CELL_PAD_LAST
|
||||
: DATA_GRID_CELL_PAD,
|
||||
cellClassName:
|
||||
index === 0
|
||||
? DATA_GRID_CELL_PAD_FIRST
|
||||
: index === columns.length - 1
|
||||
? DATA_GRID_CELL_PAD_LAST
|
||||
: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
}))
|
||||
return defs
|
||||
},
|
||||
[columns],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns: columnDefs,
|
||||
state: { globalFilter },
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
...(pagination ? { getPaginationRowModel: getPaginationRowModel() } : {}),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const displayCount = searchKeys.length > 0 ? filteredData.length : data.length
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
countLabel={`${displayCount} записей`}
|
||||
countLabel={countLabel ?? `${displayCount} записей`}
|
||||
/>
|
||||
<DataGrid
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={filteredData.length}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
pagination={pagination}
|
||||
emptyMessage={
|
||||
<EmptyState
|
||||
icon={<InboxIcon className="size-4" />}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
className="py-12"
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
}
|
||||
tableLayout={{ rowBorder: true, headerBackground: true }}
|
||||
>
|
||||
<DataGridContainer border={false}>
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
{filteredData.length > 0 && <DataGridPagination className="px-5 pb-3" />}
|
||||
</DataGrid>
|
||||
</Card>
|
||||
/>
|
||||
</DataPageCard>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user