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

This commit is contained in:
Denozordec
2026-06-30 22:22:51 +07:00
parent a1a9124f3d
commit d3a2d38b37
63 changed files with 8509 additions and 3652 deletions
+29 -52
View File
@@ -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}
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>
),
},
]}
/>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по ASN, имени, префиксам…"
countLabel={`${filtered.length} ASN`}
/>
<AsnsDataGrid
asns={filtered}
isLoading={useEvoCatalog && loading && !snapshot}
pagination={useEvoCatalog}
/>
</DataPageCard>
</div>
</div>
<FileImportDialog
+3 -2
View File
@@ -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>
)}
{/* ── Настройки ────────────────────────────────────────────────── */}
+3 -2
View File
@@ -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>
)
}
+10 -322
View File
@@ -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)}
/>
))
)}
</div>
</div>
</Card>
<CertificatesDataGrid
certificates={filtered}
serverMap={serverById}
isLoading={prefsHydrated && isLive && loadState === "loading" && displayCerts.length === 0}
/>
</DataPageCard>
<CertPartReference />
</div>
+21 -117
View File
@@ -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 ── */}
+15 -129
View File
@@ -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
? "Нет данных о пробах. Проверьте сборщик uptime и настройки проб на странице мониторинга."
: "Нет проб с звездой на дашборде. Включите пробу и отметьте ★ в разделе «Мониторинг»."}
</td>
</tr>
)}
</tbody>
</table>
</div>
<DataPageCard className="rounded-none border-0 shadow-none">
<DashboardActiveProbesDataGrid
probes={activeProbesTable}
catalog={probeServerCatalog}
isLoading={isLive && probesLoading && liveProbes === null}
emptyDescription={
isLive && probesError
? "Нет данных о пробах. Проверьте сборщик uptime и настройки проб на странице мониторинга."
: "Нет проб с звездой на дашборде. Включите пробу и отметьте ★ в разделе «Мониторинг»."
}
/>
</DataPageCard>
</CardContent>
</Card>
+156 -460
View File
@@ -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">
+29 -55
View File
@@ -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}
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>
),
},
]}
/>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по домену…"
countLabel={`${filtered.length} доменов`}
/>
<DomainsDataGrid
domains={filtered}
isLoading={useEvoCatalog && loading && !snapshot}
pagination={useEvoCatalog}
/>
</DataPageCard>
</div>
</div>
<FileImportDialog
+33 -309
View File
@@ -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 =>
r.community.includes(q) ||
(communityNameMap[r.community] ?? "").toLowerCase().includes(q) ||
r.gateway.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])
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),
)
}, [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}
tunnelsList={allTunnels}
serversList={allServers}
communityNameMap={communityNameMap}
recursiveRoutes={recRoutesByServer[selectedServerId] ?? []}
routerSyncStatus={
!isLive
? undefined
: !routerCompare || routerCompare.serverId !== selectedServerId
? null
: routerCompare.byCommunity[rule.community.trim()] ?? null
}
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))}
/>
))}
</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>
</>
<FiltersDataGrid
rules={search ? filteredRules : currentRules}
tunnelsList={allTunnels}
serversList={allServers}
communityNameMap={communityNameMap}
recursiveRoutes={recRoutesByServer[selectedServerId] ?? []}
routerSyncByCommunity={
!isLive || !routerCompare || routerCompare.serverId !== selectedServerId
? null
: routerCompare.byCommunity
}
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))}
/>
)}
{/* 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
View File
@@ -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
View File
@@ -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 */}
+30 -53
View File
@@ -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}
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>
),
},
]}
/>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по CIDR, ASN…"
countLabel={`${filtered.length} диапазонов`}
/>
<IpRangesDataGrid
ipRanges={filtered}
isLoading={useEvoCatalog && loading && !snapshot}
pagination={useEvoCatalog}
/>
</DataPageCard>
</div>
</div>
<FileImportDialog
+18 -151
View File
@@ -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
View File
@@ -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">
+14 -157
View File
@@ -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))}
/>
))}
</tbody>
</table>
{groupedRoutes.length === 0 && (
<div className="p-8 text-center text-sm text-muted-foreground">
Нет маршрутов в БД для этого сервера. Нажми &quot;Router =&gt; DB&quot; для загрузки.
</div>
)}
</div>
<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))}
/>
<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" />
+16 -370
View File
@@ -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 && (
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)}
+18 -140
View File
@@ -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)}
/>
</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>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, VNI, серверу…"
countLabel={`${filtered.length} туннелей`}
/>
<VxlanDataGrid
tunnels={filtered}
servers={servers}
onExport={setExportTunnel}
/>
</DataPageCard>
{/* Reference */}
<Card>
+24 -240
View File
@@ -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 }: {
@@ -310,8 +135,7 @@ function ExportSheet({ open, iface, onClose }: {
export default function WireGuardPage() {
const allIfaces = useMemo(() => collectInterfaces(), [])
const [search, setSearch] = useState("")
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState("")
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)}
/>
</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"
/>
) : (
filtered.map((iface) => (
<IfaceRow
key={iface.id}
iface={iface}
expanded={expandedIds.has(iface.id)}
onToggleExpand={() => toggleExpand(iface.id)}
onExport={() => setExportIface(iface)}
/>
))
)}
</Card>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, серверу, IP…"
countLabel={`${filtered.length} интерфейсов`}
/>
<WireguardDataGrid
interfaces={filtered}
onExport={setExportIface}
/>
</DataPageCard>
{/* RouterOS reference */}
<Card>