Compare commits

...
5 Commits
Author SHA1 Message Date
Denozordec fe32c9313a feat(ui): integrate KpiStatGrid for enhanced statistics display
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries.
2026-09-06 17:58:05 +07:00
Denozordec 6123660346 feat(terminal, wireguard): enhance UI components and functionality
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m36s
Docker images / frontend-image (push) Successful in 2m45s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 10s
Updated the TerminalPage to include a new ServerTileRail for server selection and added QuickCmds for quick command execution. Enhanced the WireGuardPage with new tabs for interface and peer management, improved server selection handling, and added support for displaying peer details. Refactored data grids to support compact server views and improved empty state handling. Introduced new hooks for managing server states and improved user experience across components.
2026-09-06 01:23:40 +07:00
Denozordec b680f882cc feat(ui): enhance CodeExportSheet and WgExportSheet components
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m40s
Docker images / frontend-image (push) Successful in 2m47s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 38s
Docker images / publish-release (push) Successful in 8s
Updated CodeExportSheet to improve UI elements, including the addition of a Badge component for live status indication and refined layout adjustments. Modified WgExportSheet to simplify format labels and incorporate live content fetching capabilities. Adjusted styles for better visual consistency and user experience.
2026-09-06 00:23:51 +07:00
DenozordecandCursor 5e0c16e808 fix(ui): выровнять confirms и токены под ReUI PRO
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m31s
Docker images / frontend-image (push) Successful in 2m11s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 40s
Docker images / publish-release (push) Successful in 10s
Добавить ui-design-contract, AlertDialog вместо hand-roll, CodeExportSheet для preview фильтров, ReUI Badge и gap вместо space-y.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 00:12:57 +07:00
DenozordecandCursor 66509b26bd fix(ui): выровнять экспорт WireGuard под ReUI Frame
Убрать дубль копирования, вынести CodeExportSheet и семантические токены.
Открывать вкладку Peer при экспорте пира. Мигрировать VXLAN/Firewall/Containers/GRE.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 00:12:50 +07:00
61 changed files with 7309 additions and 2152 deletions

No files matched your search

+34 -23
View File
@@ -8,8 +8,7 @@ import { FileImportDialog } from "@/components/file-import-dialog"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit" import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { StatusBadge } from "@/components/status-badge" import { StatusBadge } from "@/components/status-badge"
import type { Backup, Server } from "@/lib/data" import type { Backup, Server } from "@/lib/data"
import { Frame, FramePanel } from "@/components/reui/frame" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -343,27 +342,39 @@ export default function BackupsPage() {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{/* Stats */} <KpiStatGrid
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> aria-label="Сводка бэкапов"
{[ items={[
{ label: "Всего бэкапов", value: backupList.length, icon: <HardDriveIcon className="size-4" /> }, {
{ label: "Авто", value: autoCount, icon: <ClockIcon className="size-4" /> }, id: "all",
{ label: "Вручную", value: manualCount, icon: <PlusIcon className="size-4" /> }, label: "Всего бэкапов",
{ label: "Серверов охвачено",value: serverCount, icon: <ServerIcon className="size-4" /> }, value: backupList.length,
].map((s) => ( icon: <HardDriveIcon className="size-4" />,
<Frame key={s.label} className="h-full"> iconClassName: "text-muted-foreground",
<FramePanel className="relative isolate flex h-full items-center gap-3"> },
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground"> {
{s.icon} id: "auto",
</IconTile> label: "Авто",
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> value: autoCount,
<p className="text-muted-foreground text-sm font-medium">{s.label}</p> icon: <ClockIcon className="size-4" />,
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p> iconClassName: "text-info",
</div> },
</FramePanel> {
</Frame> id: "manual",
))} label: "Вручную",
</div> value: manualCount,
icon: <PlusIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "servers",
label: "Серверов охвачено",
value: serverCount,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{/* Info bar */} {/* Info bar */}
<div className="flex items-center gap-4 text-xs text-muted-foreground px-1"> <div className="flex items-center gap-4 text-xs text-muted-foreground px-1">
+69 -33
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 type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
import { BGP_AS_NAMES } from "@/lib/bgp/types" import { BGP_AS_NAMES } from "@/lib/bgp/types"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -20,7 +21,7 @@ import {
RefreshCwIcon, DownloadIcon, SearchIcon, RefreshCwIcon, DownloadIcon, SearchIcon,
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon, ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
ArrowDownIcon, ClipboardCopyIcon, ServerIcon, ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
XIcon, AlertCircleIcon, XIcon, AlertCircleIcon, GitMergeIcon, CheckCircleIcon,
} from "lucide-react" } from "lucide-react"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
@@ -463,22 +464,39 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
return ( return (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{/* summary row */} <KpiStatGrid
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3"> aria-label="Сводка префиксов BGP"
{[ items={[
{ label: "Всего префиксов", value: fmtNum(totalRx), color: "text-emerald-600 dark:text-emerald-400" }, {
{ label: "Активных маршрутов", value: fmtNum(totalActive), color: "text-sky-600 dark:text-sky-400" }, id: "rx",
{ label: "eBGP сессий", value: ebgpSessions, color: "" }, label: "Всего префиксов",
{ label: "iBGP сессий", value: ibgpSessions, color: "" }, value: fmtNum(totalRx),
].map(s => ( icon: <DownloadIcon className="size-4" />,
<Frame key={s.label} className="h-full"> iconClassName: "text-success",
<FramePanel className="flex flex-col gap-0.5"> },
<p className="text-muted-foreground text-sm font-medium">{s.label}</p> {
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p> id: "active",
</FramePanel> label: "Активных маршрутов",
</Frame> value: fmtNum(totalActive),
))} icon: <GitMergeIcon className="size-4" />,
</div> iconClassName: "text-info",
},
{
id: "ebgp",
label: "eBGP сессий",
value: ebgpSessions,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "ibgp",
label: "iBGP сессий",
value: ibgpSessions,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
]}
/>
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5"> <div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5">
{/* prefixes by peer — horizontal bar chart */} {/* prefixes by peer — horizontal bar chart */}
@@ -722,22 +740,40 @@ export default function BgpPage() {
</span> </span>
)} )}
{/* KPI strip */} <KpiStatGrid
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3"> aria-label="Сводка BGP"
{[ items={[
{ label: "Сессий всего", value: sessions.length, color: "" }, {
{ label: "Established", value: established, color: "text-emerald-600 dark:text-emerald-400" }, id: "sessions",
{ label: "Не установлено", value: notEstab, color: notEstab > 0 ? "text-amber-500" : "text-muted-foreground" }, label: "Сессий всего",
{ label: "Получено префиксов", value: fmtNum(totalRx),color: "" }, value: sessions.length,
].map(s => ( icon: <GitMergeIcon className="size-4" />,
<Frame key={s.label} className="h-full"> iconClassName: "text-muted-foreground",
<FramePanel className="flex flex-col gap-0.5"> },
<p className="text-muted-foreground text-sm font-medium">{s.label}</p> {
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p> id: "established",
</FramePanel> label: "Established",
</Frame> value: established,
))} icon: <CheckCircleIcon className="size-4" />,
</div> iconClassName: "text-success",
},
{
id: "not-estab",
label: "Не установлено",
value: notEstab,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground",
variant: notEstab > 0 ? "warning" : "default",
},
{
id: "prefixes",
label: "Получено префиксов",
value: fmtNum(totalRx),
icon: <DownloadIcon className="size-4" />,
iconClassName: "text-info",
},
]}
/>
{/* alert: not-established sessions */} {/* alert: not-established sessions */}
{notEstab > 0 && ( {notEstab > 0 && (
+100 -60
View File
@@ -7,12 +7,13 @@ import { FileImportDialog } from "@/components/file-import-dialog"
import { routerCertificates, servers as mockServers } from "@/lib/data" import { routerCertificates, servers as mockServers } from "@/lib/data"
import type { CertStatus, Server } from "@/lib/data" import type { CertStatus, Server } from "@/lib/data"
import type { CertificateDto } from "@mmapp/contracts/certificates" import type { CertificateDto } from "@mmapp/contracts/certificates"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar" import { DataPageToolbar } from "@/components/data-page-toolbar"
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid" import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { import {
@@ -119,42 +120,41 @@ function CertPartKpi({
expired: CertificateDto[] expired: CertificateDto[]
}) { }) {
return ( return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <KpiStatGrid
{[ aria-label="Сводка сертификатов"
items={[
{ {
id: "all",
label: "Всего", label: "Всего",
value: displayCerts.length, value: displayCerts.length,
icon: <ShieldCheckIcon className="size-4 text-muted-foreground" />, icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-muted-foreground",
}, },
{ {
id: "valid",
label: "Действующих", label: "Действующих",
value: displayCerts.filter((c) => c.status === "valid").length, value: displayCerts.filter((c) => c.status === "valid").length,
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />, icon: <BadgeCheckIcon className="size-4" />,
iconClassName: "text-success",
}, },
{ {
id: "expiring",
label: "Истекают", label: "Истекают",
value: expiring.length, value: expiring.length,
icon: <AlertTriangleIcon className="size-4 text-amber-500" />, icon: <AlertTriangleIcon className="size-4" />,
iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground",
variant: expiring.length > 0 ? "warning" : "default",
}, },
{ {
id: "expired",
label: "Истёкших", label: "Истёкших",
value: expired.length, value: expired.length,
icon: <AlertCircleIcon className="size-4 text-red-500" />, icon: <AlertCircleIcon className="size-4" />,
iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground",
variant: expired.length > 0 ? "destructive" : "default",
}, },
].map((s) => ( ]}
<Frame key={s.label} className="h-full"> />
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
) )
} }
@@ -437,6 +437,8 @@ export default function CertificatesPage() {
const [issueTrustWww, setIssueTrustWww] = useState(true) const [issueTrustWww, setIssueTrustWww] = useState(true)
const [issueTrustApi, setIssueTrustApi] = useState(true) const [issueTrustApi, setIssueTrustApi] = useState(true)
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState( const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState(
"https://acme-v02.api.letsencrypt.org/directory", "https://acme-v02.api.letsencrypt.org/directory",
) )
@@ -451,6 +453,27 @@ export default function CertificatesPage() {
return routerCertificates.map(mockToDto) return routerCertificates.map(mockToDto)
}, [prefsHydrated, isLive, certificates]) }, [prefsHydrated, isLive, certificates])
const displayServers = isLive ? serverList : mockServers
const scopedCerts = useMemo(() => {
if (selectedServerId === ALL_SERVERS_ID) return displayCerts
return displayCerts.filter((c) => c.serverId === selectedServerId)
}, [displayCerts, selectedServerId])
const certRailItems = useMemo<ServerTileItem[]>(() => (
displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
enabled: s.enabled,
meta: String(displayCerts.filter((c) => c.serverId === s.id).length),
}))
), [displayServers, displayCerts])
const serverById = useMemo(() => { const serverById = useMemo(() => {
const map = new Map<string, Server>() const map = new Map<string, Server>()
for (const s of isLive ? serverList : mockServers) map.set(s.id, s) for (const s of isLive ? serverList : mockServers) map.set(s.id, s)
@@ -515,13 +538,13 @@ export default function CertificatesPage() {
}, [isLive, loadLive, loadAcmeSettings]) }, [isLive, loadLive, loadAcmeSettings])
const expiring = useMemo( const expiring = useMemo(
() => displayCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30), () => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
[displayCerts], [scopedCerts],
) )
const expired = useMemo(() => displayCerts.filter((c) => c.status === "expired"), [displayCerts]) const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts])
const filtered = useMemo(() => { const filtered = useMemo(() => {
return displayCerts.filter((c) => { return scopedCerts.filter((c) => {
if (statusFilter !== "all" && c.status !== statusFilter) return false if (statusFilter !== "all" && c.status !== statusFilter) return false
if (!search) return true if (!search) return true
const q = search.toLowerCase() const q = search.toLowerCase()
@@ -532,7 +555,7 @@ export default function CertificatesPage() {
c.sans.some((s) => s.includes(q)) c.sans.some((s) => s.includes(q))
) )
}) })
}, [displayCerts, search, statusFilter]) }, [scopedCerts, search, statusFilter])
async function handleRefresh() { async function handleRefresh() {
if (!liveReady) return if (!liveReady) return
@@ -625,35 +648,52 @@ export default function CertificatesPage() {
} }
return ( return (
<div className="flex flex-col h-full"> <>
<PageHeader <ServerRailLayout
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]} items={certRailItems}
actions={ selectedId={selectedServerId}
<> onSelect={setSelectedServerId}
<Button showAll
variant="outline" allCount={displayServers.length}
size="sm" loading={isLive && loadState === "loading" && displayServers.length === 0}
disabled={!liveReady || loadState === "loading"} header={
onClick={() => { <PageHeader
void handleRefresh() crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
}} actions={
> <>
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} /> <ServerRailMobileButton />
Обновить <Button
</Button> variant="outline"
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}> size="sm"
<UploadIcon className="size-4" /> disabled={!liveReady || loadState === "loading"}
Импорт onClick={() => {
</Button> void handleRefresh()
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => { setIssueStep(1); setIssueOpen(true) }}> }}
<PlusIcon className="size-4" /> >
Выпустить сертификат <RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
</Button> Обновить
</> </Button>
} <Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
/> <UploadIcon className="size-4" />
Импорт
<div className="flex-1 overflow-y-auto p-6"> </Button>
<Button
size="sm"
disabled={!liveReady || issueBusy}
onClick={() => {
setIssueStep(1)
if (selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
setIssueOpen(true)
}}
>
<PlusIcon className="size-4" />
Выпустить сертификат
</Button>
</>
}
/>
}
>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{isLive && backendStatus === false && ( {isLive && backendStatus === false && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-700 dark:text-amber-300"> <div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
@@ -673,7 +713,7 @@ export default function CertificatesPage() {
</div> </div>
)} )}
<CertPartKpi displayCerts={displayCerts} expiring={expiring} expired={expired} /> <CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
{liveReady && ( {liveReady && (
<CertPartAcmeSettings <CertPartAcmeSettings
@@ -720,7 +760,7 @@ export default function CertificatesPage() {
<CertPartReference /> <CertPartReference />
</div> </div>
</div> </ServerRailLayout>
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}> <Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0"> <SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
@@ -753,7 +793,7 @@ export default function CertificatesPage() {
<StepperContent key={s} value={s}> <StepperContent key={s} value={s}>
<CertPartIssueForm <CertPartIssueForm
step={s as 1 | 2 | 3 | 4} step={s as 1 | 2 | 3 | 4}
serverList={serverList} serverList={displayServers}
issueServerId={issueServerId} issueServerId={issueServerId}
setIssueServerId={setIssueServerId} setIssueServerId={setIssueServerId}
issueCertName={issueCertName} issueCertName={issueCertName}
@@ -812,6 +852,6 @@ export default function CertificatesPage() {
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`) toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
}} }}
/> />
</div> </>
) )
} }
+34 -16
View File
@@ -11,6 +11,7 @@ import {
ACTION_COLOR, ACTION_COLOR,
} from "@/components/data-grids/communities-data-grid" } from "@/components/data-grids/communities-data-grid"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -160,22 +161,39 @@ export default function CommunitiesPage() {
</p> </p>
)} )}
{/* ── summary ── */} <KpiStatGrid
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> aria-label="Сводка communities"
{[ items={[
{ label: "Всего communities", value: String(listData.length) }, {
{ label: "Активных", value: String(listData.filter(c => c.enabled).length) }, id: "all",
{ label: "Стандартных", value: String(listData.filter(c => c.type === "standard").length) }, label: "Всего communities",
{ label: "Использует фильтры",value: String(new Set(listData.flatMap(c => c.filterIds)).size) }, value: String(listData.length),
].map(({ label, value }) => ( icon: <TagIcon className="size-4" />,
<Frame key={label} className="h-full"> iconClassName: "text-muted-foreground",
<FramePanel className="flex flex-col gap-0.5"> },
<p className="text-muted-foreground text-sm font-medium">{label}</p> {
<p className="text-2xl leading-none font-bold tabular-nums">{value}</p> id: "enabled",
</FramePanel> label: "Активных",
</Frame> value: String(listData.filter((c) => c.enabled).length),
))} icon: <CheckIcon className="size-4" />,
</div> iconClassName: "text-success",
},
{
id: "standard",
label: "Стандартных",
value: String(listData.filter((c) => c.type === "standard").length),
icon: <TagIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "filters",
label: "Использует фильтры",
value: String(new Set(listData.flatMap((c) => c.filterIds)).size),
icon: <FilterIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
<div className="grid grid-cols-[1fr_320px] gap-5"> <div className="grid grid-cols-[1fr_320px] gap-5">
{/* ── main table ── */} {/* ── main table ── */}
+51 -71
View File
@@ -6,7 +6,7 @@ import { routerContainers, servers } from "@/lib/data"
import type { RouterContainer } from "@/lib/data" import type { RouterContainer } from "@/lib/data"
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -17,13 +17,14 @@ import {
import { import {
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon, BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon, MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
CodeXmlIcon, CopyIcon, CheckIcon, ActivityIcon, ServerIcon, CodeXmlIcon, ActivityIcon, ServerIcon,
TerminalIcon, AlertCircleIcon, TerminalIcon, AlertCircleIcon,
} from "lucide-react" } from "lucide-react"
import { import {
Sheet, SheetContent, SheetHeader, SheetTitle, Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose, SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet" } from "@/components/ui/sheet"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
// ─── helpers ────────────────────────────────────────────────────────────────── // ─── helpers ──────────────────────────────────────────────────────────────────
@@ -104,57 +105,23 @@ function generateContainerRsc(c: RouterContainer): string {
function ExportSheet({ open, container, onClose }: { function ExportSheet({ open, container, onClose }: {
open: boolean; container: RouterContainer | null; onClose: () => void open: boolean; container: RouterContainer | null; onClose: () => void
}) { }) {
const [copied, setCopied] = useState(false)
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container]) const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
function handleCopy() {
navigator.clipboard.writeText(code).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 2000)
})
}
return ( return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}> <CodeExportSheet
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl"> open={open}
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b"> onClose={onClose}
<div className="flex items-start justify-between gap-4"> title="Экспорт Container"
<div> description="RouterOS 7.4+ · /container · /interface/veth"
<SheetTitle>Экспорт Container</SheetTitle> formats={[
<SheetDescription>RouterOS 7.4+ · /container · /interface/veth</SheetDescription> {
</div> id: "rsc",
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}> label: "MikroTik .rsc",
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>} filename: `${container?.name ?? "container"}.rsc`,
</Button> code,
</div> },
</SheetHeader> ]}
<div className="flex-1 overflow-y-auto"> />
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isCmd = /^\//.test(line.trimStart())
const isParam = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isComment ? "text-muted-foreground"
: isCmd ? "text-sky-400"
: isParam ? "text-violet-300"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1" onClick={handleCopy}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
) )
} }
@@ -319,27 +286,40 @@ export default function ContainersPage() {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{/* KPI */} <KpiStatGrid
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> aria-label="Сводка контейнеров"
{[ items={[
{ label: "Всего", value: routerContainers.length, icon: <BoxIcon className="size-4 text-muted-foreground" /> }, {
{ label: "Running", value: running, icon: <PlayIcon className="size-4 text-emerald-500" /> }, id: "all",
{ label: "Stopped", value: stopped, icon: <StopCircleIcon className="size-4 text-muted-foreground" /> }, label: "Всего",
{ label: "Ошибок", value: errors, icon: <AlertCircleIcon className="size-4 text-red-500" /> }, value: routerContainers.length,
].map((s) => ( icon: <BoxIcon className="size-4" />,
<Frame key={s.label} className="h-full"> iconClassName: "text-muted-foreground",
<FramePanel className="relative isolate flex h-full items-start gap-3"> },
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground"> {
{s.icon} id: "running",
</IconTile> label: "Running",
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> value: running,
<p className="text-muted-foreground text-sm font-medium">{s.label}</p> icon: <PlayIcon className="size-4" />,
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p> iconClassName: "text-success",
</div> },
</FramePanel> {
</Frame> id: "stopped",
))} label: "Stopped",
</div> value: stopped,
icon: <StopCircleIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "errors",
label: "Ошибок",
value: errors,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: "text-destructive",
variant: errors > 0 ? "destructive" : "default",
},
]}
/>
{/* Info banner */} {/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm"> <div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm">
+49 -90
View File
@@ -1,15 +1,13 @@
"use client" "use client"
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { Frame, FramePanel } from "@/components/reui/frame" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { IconTile } from "@/components/reui/icon-tile"
import { StatusDot } from "@/components/status-dot" import { StatusDot } from "@/components/status-dot"
import { StatusBadge } from "@/components/status-badge" import { StatusBadge } from "@/components/status-badge"
import { Sparkline } from "@/components/sparkline"
import { LatencyChart } from "@/components/dashboard/latency-chart" import { LatencyChart } from "@/components/dashboard/latency-chart"
import { BandwidthChart } from "@/components/dashboard/bandwidth-chart" import { BandwidthChart } from "@/components/dashboard/bandwidth-chart"
import { InternetPathMapCard } from "@/components/dashboard/internet-path-map" import { InternetPathMapCard } from "@/components/dashboard/internet-path-map"
@@ -47,49 +45,6 @@ function makeApiFetch(backendUrl: string) {
} }
} }
function StatCard({
label, value, unit, delta, deltaDir, spark, sparkColor, icon,
}: {
label: string; value: string; unit?: string; delta?: string
deltaDir?: "up" | "down"; spark?: number[]; sparkColor?: string
icon?: ReactNode
}) {
return (
<Frame className="h-full">
<FramePanel className="relative isolate flex h-full flex-col overflow-hidden">
<div className="relative z-10 flex items-start gap-3">
{icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className="size-10.5 text-muted-foreground"
>
{icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{label}</p>
<div className="flex items-baseline gap-1.5">
<span className="text-2xl leading-none font-bold tabular-nums tracking-tight">{value}</span>
{unit ? <span className="text-muted-foreground text-sm">{unit}</span> : null}
</div>
{delta ? (
<p className={`text-xs mt-1 flex items-center gap-1 ${deltaDir === "up" ? "text-[var(--status-online-fg)]" : deltaDir === "down" ? "text-[var(--status-offline-fg)]" : "text-muted-foreground"}`}>
{delta}
</p>
) : null}
</div>
</div>
{spark && spark.length > 1 ? (
<div className="absolute right-4 bottom-4 opacity-60">
<Sparkline data={spark} width={80} height={32} color={sparkColor ?? "currentColor"} filled />
</div>
) : null}
</FramePanel>
</Frame>
)
}
function fmtIntRu(n: number): string { function fmtIntRu(n: number): string {
return n.toLocaleString("ru-RU") return n.toLocaleString("ru-RU")
} }
@@ -725,49 +680,53 @@ export default function DashboardPage() {
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="p-6 flex flex-col gap-6"> <div className="p-6 flex flex-col gap-6">
{/* KPI row */} <KpiStatGrid
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> aria-label="Сводка дашборда"
<StatCard items={[
label="Серверы онлайн" {
value={dashboardKpi.servers.value} id: "servers",
unit={dashboardKpi.servers.unit} label: "Серверы онлайн",
delta={dashboardKpi.servers.delta} value: dashboardKpi.servers.unit
deltaDir={dashboardKpi.servers.deltaDir} ? `${dashboardKpi.servers.value} ${dashboardKpi.servers.unit}`
spark={dashboardKpi.servers.spark} : dashboardKpi.servers.value,
sparkColor={dashboardKpi.servers.sparkColor} hint: dashboardKpi.servers.delta,
icon={<ServerIcon aria-hidden />} icon: <ServerIcon className="size-4" />,
/> iconClassName: "text-muted-foreground",
<StatCard variant: dashboardKpi.servers.deltaDir === "down" ? "warning" : "default",
label="Активные фильтры" },
value={dashboardKpi.filters.value} {
unit={dashboardKpi.filters.unit} id: "filters",
delta={dashboardKpi.filters.delta} label: "Активные фильтры",
deltaDir={dashboardKpi.filters.deltaDir} value: dashboardKpi.filters.unit
spark={dashboardKpi.filters.spark} ? `${dashboardKpi.filters.value} ${dashboardKpi.filters.unit}`
sparkColor={dashboardKpi.filters.sparkColor} : dashboardKpi.filters.value,
icon={<FilterIcon aria-hidden />} hint: dashboardKpi.filters.delta,
/> icon: <FilterIcon className="size-4" />,
<StatCard iconClassName: "text-info",
label="BGP-префиксы" },
value={dashboardKpi.bgp.value} {
unit={dashboardKpi.bgp.unit} id: "bgp",
delta={dashboardKpi.bgp.delta} label: "BGP-префиксы",
deltaDir={dashboardKpi.bgp.deltaDir} value: dashboardKpi.bgp.unit
spark={dashboardKpi.bgp.spark} ? `${dashboardKpi.bgp.value} ${dashboardKpi.bgp.unit}`
sparkColor={dashboardKpi.bgp.sparkColor} : dashboardKpi.bgp.value,
icon={<GitMergeIcon aria-hidden />} hint: dashboardKpi.bgp.delta,
/> icon: <GitMergeIcon className="size-4" />,
<StatCard iconClassName: "text-primary",
label="Активные алерты" },
value={dashboardKpi.alerts.value} {
unit={dashboardKpi.alerts.unit} id: "alerts",
delta={dashboardKpi.alerts.delta} label: "Активные алерты",
deltaDir={dashboardKpi.alerts.deltaDir} value: dashboardKpi.alerts.unit
spark={dashboardKpi.alerts.spark} ? `${dashboardKpi.alerts.value} ${dashboardKpi.alerts.unit}`
sparkColor={dashboardKpi.alerts.sparkColor} : dashboardKpi.alerts.value,
icon={<BellIcon aria-hidden />} hint: dashboardKpi.alerts.delta,
/> icon: <BellIcon className="size-4" />,
</div> iconClassName: dashboardKpi.alerts.deltaDir === "down" ? "text-destructive" : "text-muted-foreground",
variant: dashboardKpi.alerts.deltaDir === "down" ? "destructive" : "default",
},
]}
/>
{/* Latency chart + Events */} {/* Latency chart + Events */}
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4"> <div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4">
+60 -51
View File
@@ -4,10 +4,9 @@ import Link from "next/link"
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit" import { FormToggle } from "@/components/form-kit"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/reui/badge"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Frame, FramePanel } from "@/components/reui/frame" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { import {
@@ -104,7 +103,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "traffic") { if (snap.job === "traffic") {
const t = snap as TrafficRunSnapshot const t = snap as TrafficRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
{t.skipped ? ( {t.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор трафика ещё выполнялся.</p> <p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор трафика ещё выполнялся.</p>
) : null} ) : null}
@@ -126,7 +125,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "uptime_resources") { if (snap.job === "uptime_resources") {
const u = snap as ResourcesRunSnapshot const u = snap as ResourcesRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
{u.skipped ? ( {u.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ресурсов уже выполняется.</p> <p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ресурсов уже выполняется.</p>
) : null} ) : null}
@@ -148,7 +147,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "servers_rest_ping") { if (snap.job === "servers_rest_ping") {
const s = snap as ServersRestPingRunSnapshot const s = snap as ServersRestPingRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
{s.skipped ? ( {s.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка API ещё выполнялась.</p> <p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка API ещё выполнялась.</p>
) : null} ) : null}
@@ -171,7 +170,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "uptime_ping") { if (snap.job === "uptime_ping") {
const p = snap as PingRunSnapshot const p = snap as PingRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
{p.skipped ? ( {p.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ping уже выполняется.</p> <p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ping уже выполняется.</p>
) : null} ) : null}
@@ -196,7 +195,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "uptime_speed") { if (snap.job === "uptime_speed") {
const s = snap as SpeedScheduledRunSnapshot const s = snap as SpeedScheduledRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> — по очереди для каждой включённой пробы Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> — по очереди для каждой включённой пробы
</p> </p>
@@ -209,7 +208,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "gre_bgp") { if (snap.job === "gre_bgp") {
const g = snap as GreBgpSnapshotRunSnapshot const g = snap as GreBgpSnapshotRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
{g.skipped ? ( {g.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p> <p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p>
) : null} ) : null}
@@ -235,7 +234,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div> </div>
</dl> </dl>
{g.errors?.length ? ( {g.errors?.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1"> <div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{g.errors.map((e, i) => ( {g.errors.map((e, i) => (
<p key={i} className="break-words"> <p key={i} className="break-words">
{e} {e}
@@ -249,7 +248,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "internet_path") { if (snap.job === "internet_path") {
const p = snap as InternetPathRunSnapshot const p = snap as InternetPathRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Снимок internet-path на{" "} Снимок internet-path на{" "}
<span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span> <span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span>
@@ -276,7 +275,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "certificates_renew") { if (snap.job === "certificates_renew") {
const c = snap as CertificatesRenewRunSnapshot const c = snap as CertificatesRenewRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
{c.skipped ? ( {c.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p> <p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p>
) : null} ) : null}
@@ -299,7 +298,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div> </div>
</dl> </dl>
{c.errors.length ? ( {c.errors.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1"> <div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{c.errors.map((e, i) => ( {c.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p> <p key={i} className="break-words">{e}</p>
))} ))}
@@ -311,7 +310,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "backups") { if (snap.job === "backups") {
const b = snap as BackupsRunSnapshot const b = snap as BackupsRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4"> <dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<div> <div>
<dt className="text-muted-foreground">Слот расписания</dt> <dt className="text-muted-foreground">Слот расписания</dt>
@@ -331,7 +330,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div> </div>
</dl> </dl>
{b.errors?.length ? ( {b.errors?.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1"> <div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{b.errors.map((e, i) => ( {b.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p> <p key={i} className="break-words">{e}</p>
))} ))}
@@ -343,7 +342,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "alert_engine") { if (snap.job === "alert_engine") {
const a = snap as AlertEngineRunSnapshot const a = snap as AlertEngineRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Снимок на{" "} Снимок на{" "}
<span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span> <span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span>
@@ -370,7 +369,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
{a.errors?.length ? ( {a.errors?.length ? (
<Alert variant="destructive" className="py-2"> <Alert variant="destructive" className="py-2">
<AlertCircleIcon /> <AlertCircleIcon />
<AlertDescription className="space-y-1 text-xs"> <AlertDescription className="flex flex-col gap-1 text-xs">
{a.errors.map((e, i) => ( {a.errors.map((e, i) => (
<p key={i} className="break-words"> <p key={i} className="break-words">
{e} {e}
@@ -380,7 +379,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</Alert> </Alert>
) : null} ) : null}
{a.ruleDiag && a.ruleDiag.length > 0 ? ( {a.ruleDiag && a.ruleDiag.length > 0 ? (
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 space-y-2"> <div className="rounded-md border border-border bg-muted/20 px-3 py-2 flex flex-col gap-2">
<p className="text-[11px] font-medium text-muted-foreground">По правилам (почему не ушло в Telegram)</p> <p className="text-[11px] font-medium text-muted-foreground">По правилам (почему не ушло в Telegram)</p>
<DataPageCard className="border-0 shadow-none bg-transparent"> <DataPageCard className="border-0 shadow-none bg-transparent">
<AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} /> <AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} />
@@ -398,39 +397,39 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—" const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—"
const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson]) const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson])
return ( return (
<div className="space-y-4 text-sm"> <div className="flex flex-col gap-4 text-sm">
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3"> <dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
<div className="space-y-1"> <div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">ID записи</dt> <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">ID записи</dt>
<dd className="font-mono text-xs break-all bg-muted/60 rounded-md px-2 py-1.5 border border-border">{r.id}</dd> <dd className="font-mono text-xs break-all bg-muted/60 rounded-md px-2 py-1.5 border border-border">{r.id}</dd>
</div> </div>
<div className="space-y-1"> <div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Ключ задачи</dt> <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Ключ задачи</dt>
<dd className="font-mono text-xs">{r.jobKey}</dd> <dd className="font-mono text-xs">{r.jobKey}</dd>
</div> </div>
<div className="sm:col-span-2 space-y-1"> <div className="sm:col-span-2 flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Название и назначение</dt> <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Название и назначение</dt>
<dd> <dd>
<span className="font-medium">{jobTitle}</span> <span className="font-medium">{jobTitle}</span>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">{jobDesc}</p> <p className="text-xs text-muted-foreground mt-1 leading-relaxed">{jobDesc}</p>
</dd> </dd>
</div> </div>
<div className="space-y-1"> <div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Старт</dt> <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Старт</dt>
<dd className="tabular-nums text-xs">{new Date(r.startedAt).toLocaleString("ru-RU")}</dd> <dd className="tabular-nums text-xs">{new Date(r.startedAt).toLocaleString("ru-RU")}</dd>
</div> </div>
<div className="space-y-1"> <div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Завершение</dt> <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Завершение</dt>
<dd className="tabular-nums text-xs">{new Date(r.finishedAt).toLocaleString("ru-RU")}</dd> <dd className="tabular-nums text-xs">{new Date(r.finishedAt).toLocaleString("ru-RU")}</dd>
</div> </div>
<div className="space-y-1"> <div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Длительность</dt> <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Длительность</dt>
<dd className="tabular-nums"> <dd className="tabular-nums">
<span className="font-mono">{r.durationMs}</span> мс <span className="font-mono">{r.durationMs}</span> мс
<span className="text-muted-foreground text-xs ml-2">({fmtMs(r.durationMs)})</span> <span className="text-muted-foreground text-xs ml-2">({fmtMs(r.durationMs)})</span>
</dd> </dd>
</div> </div>
<div className="space-y-1"> <div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результат</dt> <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результат</dt>
<dd> <dd>
<Badge <Badge
@@ -447,7 +446,7 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
</div> </div>
</dl> </dl>
{r.error ? ( {r.error ? (
<div className="space-y-1.5"> <div className="flex flex-col gap-1.5">
<p className="text-xs font-medium text-destructive">Текст ошибки</p> <p className="text-xs font-medium text-destructive">Текст ошибки</p>
<pre <pre
className="text-xs font-mono whitespace-pre-wrap break-words rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 max-h-48 overflow-y-auto" className="text-xs font-mono whitespace-pre-wrap break-words rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 max-h-48 overflow-y-auto"
@@ -461,7 +460,7 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
<Separator /> <Separator />
<div className="space-y-2"> <div className="flex flex-col gap-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p>
{snapshot ? ( {snapshot ? (
<SnapshotTables snap={snapshot} /> <SnapshotTables snap={snapshot} />
@@ -967,13 +966,13 @@ export default function DataCollectionPage() {
sub: uptimeCollector?.scheduler?.jobs?.length sub: uptimeCollector?.scheduler?.jobs?.length
? "По сохранённым задачам планировщика" ? "По сохранённым задачам планировщика"
: "По переключателям на этой странице", : "По переключателям на этой странице",
icon: <CalendarClockIcon className="size-4 text-muted-foreground" />, icon: <CalendarClockIcon className="size-4" />,
}, },
{ {
label: "Сейчас выполняется", label: "Сейчас выполняется",
value: String(runningJobsCount), value: String(runningJobsCount),
sub: "Фоновые прогоны планировщика", sub: "Фоновые прогоны планировщика",
icon: <LoaderCircleIcon className="size-4 text-amber-500" />, icon: <LoaderCircleIcon className="size-4" />,
}, },
{ {
label: "Трафик — последний сбор", label: "Трафик — последний сбор",
@@ -982,16 +981,16 @@ export default function DataCollectionPage() {
: "—", : "—",
sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных", sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
icon: trafficCollector?.lastError ? ( icon: trafficCollector?.lastError ? (
<XCircleIcon className="size-4 text-destructive" /> <XCircleIcon className="size-4" />
) : ( ) : (
<CheckCircleIcon className="size-4 text-emerald-500" /> <CheckCircleIcon className="size-4" />
), ),
}, },
{ {
label: "Журнал (в списке)", label: "Журнал (в списке)",
value: String(schedulerRuns.length), value: String(schedulerRuns.length),
sub: errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет", sub: errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет",
icon: <DatabaseIcon className="size-4 text-sky-500" />, icon: <DatabaseIcon className="size-4" />,
}, },
], ],
[ [
@@ -1064,22 +1063,32 @@ export default function DataCollectionPage() {
{isLive && ( {isLive && (
<> <>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <KpiStatGrid
{stats.map((s) => ( aria-label="Сводка сбора данных"
<Frame key={s.label} className="h-full"> items={stats.map((s, i) => ({
<FramePanel className="relative isolate flex h-full items-start gap-3"> id: `dc-${i}`,
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground"> label: s.label,
{s.icon} value: s.value,
</IconTile> hint: s.sub,
<div className="min-w-0 flex-1 flex flex-col gap-0.5"> icon: s.icon,
<p className="text-muted-foreground text-sm font-medium">{s.label}</p> iconClassName:
<p className="text-xl leading-none font-bold tabular-nums truncate">{s.value}</p> s.label === "Трафик — последний сбор" && trafficCollector?.lastError
<p className="text-[11px] text-muted-foreground mt-1 leading-snug line-clamp-2">{s.sub}</p> ? "text-destructive"
</div> : s.label === "Сейчас выполняется" && runningJobsCount > 0
</FramePanel> ? "text-warning"
</Frame> : s.label === "Журнал (в списке)" && errorRunsInView
))} ? "text-destructive"
</div> : s.label === "Трафик — последний сбор"
? "text-success"
: "text-muted-foreground",
variant:
s.label === "Трафик — последний сбор" && trafficCollector?.lastError
? "destructive" as const
: s.label === "Журнал (в списке)" && errorRunsInView
? "warning" as const
: "default" as const,
}))}
/>
<DataPageCard> <DataPageCard>
<div className="border-b border-border px-5 py-4"> <div className="border-b border-border px-5 py-4">
@@ -1090,7 +1099,7 @@ export default function DataCollectionPage() {
</div> </div>
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} /> <DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
<Separator /> <Separator />
<div className="space-y-3 px-5 py-4"> <div className="flex flex-col gap-3 px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p> <p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
@@ -1142,7 +1151,7 @@ export default function DataCollectionPage() {
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null} {schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
Сохранить настройки планировщика Сохранить настройки планировщика
</Button> </Button>
<div className="text-xs text-muted-foreground space-y-0.5 pt-1 border-t border-border/60"> <div className="text-xs text-muted-foreground flex flex-col gap-0.5 pt-1 border-t border-border/60">
<p> <p>
Трафик — последний сбор:{" "} Трафик — последний сбор:{" "}
{trafficCollector?.lastCollectedAt {trafficCollector?.lastCollectedAt
+153 -226
View File
@@ -35,6 +35,9 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { toast } from "sonner" import { toast } from "sonner"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { type ServerTileItem } from "@/components/server-tile-rail"
// ── helpers ──────────────────────────────────────────────────────────────────── // ── helpers ────────────────────────────────────────────────────────────────────
@@ -754,13 +757,11 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
tunnelsList: GreTunnel[] tunnelsList: GreTunnel[]
recRoutesByServer: Record<string, RecursiveRouteLite[]> recRoutesByServer: Record<string, RecursiveRouteLite[]>
}) { }) {
const [copied, setCopied] = useState(false)
const singleRuleset = useMemo( const singleRuleset = useMemo(
() => rulesets.filter(r => r.serverId === serverId), () => rulesets.filter(r => r.serverId === serverId),
[rulesets, serverId], [rulesets, serverId],
) )
const server = serversList.find(s => s.id === serverId) const server = serversList.find(s => s.id === serverId)
const totalRules = singleRuleset.reduce((s, r) => s + r.rules.length, 0) const totalRules = singleRuleset.reduce((s, r) => s + r.rules.length, 0)
const config = useMemo( const config = useMemo(
@@ -771,93 +772,25 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
[open, singleRuleset, serversList, tunnelsList, recRoutesByServer], [open, singleRuleset, serversList, tunnelsList, recRoutesByServer],
) )
const handleCopy = () => {
navigator.clipboard.writeText(config).catch(() => {})
setCopied(true); setTimeout(() => setCopied(false), 2000)
}
if (!open) return null
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <CodeExportSheet
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} /> open={open}
<div className="relative z-10 w-full max-w-2xl mx-4 bg-card rounded-xl border shadow-2xl flex flex-col max-h-[85vh]"> onClose={onClose}
title="RouterOS config"
{/* header */} description={
<div className="flex items-center justify-between px-5 py-3.5 border-b shrink-0"> server
<div className="flex items-center gap-2.5"> ? `${server.name} · ${totalRules} правил`
<FileCodeIcon className="size-4 text-muted-foreground" /> : "Экспорт правил фильтрации"
<span className="text-sm font-semibold">RouterOS config</span> }
{server && ( formats={[
<span className="flex items-center gap-1 text-[11px] text-muted-foreground bg-muted px-2 py-0.5 rounded-full"> {
<Flag code={server.country} size={11} /> id: "rsc",
<span className="font-mono">{server.name}</span> label: "RouterOS",
<span>· {totalRules} правил</span> filename: `filters-${server?.name ?? "server"}.rsc`,
</span> code: config,
)} },
</div> ]}
<Button variant="ghost" size="icon-sm" onClick={onClose}><XIcon className="size-4" /></Button> />
</div>
{/* code */}
<div className="flex-1 overflow-y-auto p-4 min-h-0">
<pre className="text-xs font-mono bg-[#0d1117] rounded-lg p-4 leading-[1.6] whitespace-pre overflow-x-auto">
{config.split("\n").map((line, i) => {
const trimmed = line.trimStart()
const cls =
// section dividers
trimmed.startsWith("# ═") || trimmed.startsWith("# ─")
? "text-[#444c56]" :
// inline comments inside rule body
trimmed.startsWith("# ")
? "text-[#8b949e]" :
// RouterOS scripting keywords
trimmed.startsWith(":local") || trimmed.startsWith(":log") || trimmed.startsWith(":foreach")
? "text-[#d2a8ff]" :
// :if identity branch / closing brace
trimmed.startsWith(":if") || (trimmed === "}" && line.length < 3)
? "text-[#ff7b72] font-semibold" :
// filter rule add command
trimmed.startsWith("/routing filter rule")
? "text-[#79c0ff]" :
// rule body: if/else if branches
trimmed.startsWith("if (") || trimmed.startsWith("} else if")
? "text-[#ff7b72]" :
// rule body: blackhole action
trimmed.startsWith("set type blackhole")
? "text-[#ff7b72] font-semibold" :
// rule body: set actions
trimmed.startsWith("set gw") || trimmed.startsWith("set gateway") || trimmed.startsWith("set out-interface")
? "text-[#a5d6ff]" :
// rule body: accept / rule close
trimmed.startsWith("accept") || trimmed === `}"` || trimmed.startsWith(`rule="`)
? "text-[#79c0ff]" :
// named params
trimmed.match(/^(chain|comment|bgp-communities)=/)
? "text-[#a5d6ff]" :
"text-[#c9d1d9]"
return <span key={i} className={cn("block", cls)}>{line || " "}</span>
})}
</pre>
</div>
{/* footer */}
<div className="px-5 py-4 border-t bg-muted/30 shrink-0 space-y-3">
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs text-muted-foreground">
<p>1. Скопируйте скрипт в буфер обмена</p>
<p>3. Вставьте в терминал и нажмите Enter</p>
<p>2. Подключитесь к любому MikroTik (SSH / Winbox)</p>
<p>4. Скрипт сам определит свои правила по identity</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose} className="flex-1">Закрыть</Button>
<Button onClick={handleCopy} className="flex-1">
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано!" : "Скопировать"}
</Button>
</div>
</div>
</div>
</div>
) )
} }
@@ -1470,6 +1403,25 @@ export default function FiltersPage() {
const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0] const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0]
const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0) const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0)
const filterRailItems = useMemo<ServerTileItem[]>(() => (
allServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
enabled: s.enabled,
meta: String(rulesets.find((r) => r.serverId === s.id)?.rules.length ?? 0),
}))
), [allServers, rulesets])
const handleSelectServer = useCallback((id: string) => {
setSelectedServerId(id)
setSearch("")
}, [])
const currentRules = useMemo( const currentRules = useMemo(
() => rulesets.find(r => r.serverId === selectedServerId)?.rules ?? [], () => rulesets.find(r => r.serverId === selectedServerId)?.rules ?? [],
[rulesets, selectedServerId], [rulesets, selectedServerId],
@@ -1635,142 +1587,117 @@ export default function FiltersPage() {
} }
return ( return (
<div className="flex flex-col h-full"> <>
<PageHeader <ServerRailLayout
crumbs={[{ label: "Управление" }, { label: "Фильтры" }]} items={filterRailItems}
actions={ selectedId={selectedServerId}
<> onSelect={handleSelectServer}
{isLive && ( showAll={false}
<> header={
<Button <PageHeader
variant="outline" crumbs={[{ label: "Управление" }, { label: "Фильтры" }]}
size="sm" actions={
onClick={syncFromRouter} <>
disabled={syncBusy !== null} <ServerRailMobileButton />
title="Синхронизация Router → БД" {isLive && (
> <>
{syncBusy === "from" ? "Синк Router → DB…" : "Router → DB"} <Button
</Button> variant="outline"
<Button size="sm"
variant="outline" onClick={syncFromRouter}
size="sm" disabled={syncBusy !== null}
onClick={syncToRouter} title="Синхронизация Router → БД"
disabled={syncBusy !== null} >
title="Синхронизация БД → Router" {syncBusy === "from" ? "Синк Router → DB…" : "Router → DB"}
> </Button>
{syncBusy === "to" ? "Синк DB → Router…" : "DB → Router"} <Button
</Button> variant="outline"
<Button size="sm"
variant="outline" onClick={syncToRouter}
size="sm" disabled={syncBusy !== null}
onClick={() => void fetchRouterCompare()} title="Синхронизация БД → Router"
disabled={syncBusy !== null || routerCompareLoading} >
title="Сравнить правила в БД с цепочкой bgp-in на MikroTik" {syncBusy === "to" ? "Синк DB → Router…" : "DB → Router"}
className="gap-1.5" </Button>
> <Button
{routerCompareLoading ? ( variant="outline"
<LoaderCircleIcon className="size-4 animate-spin" /> size="sm"
) : ( onClick={() => void fetchRouterCompare()}
<RefreshCwIcon className="size-4" /> disabled={syncBusy !== null || routerCompareLoading}
)} title="Сравнить правила в БД с цепочкой bgp-in на MikroTik"
Сверить className="gap-1.5"
</Button> >
</> {routerCompareLoading ? (
)} <LoaderCircleIcon className="size-4 animate-spin" />
<Button variant="outline" size="sm" onClick={() => setPreviewOpen(true)}> ) : (
<FileCodeIcon className="size-4" />RouterOS <RefreshCwIcon className="size-4" />
</Button> )}
<Button Сверить
variant="outline" size="sm" </Button>
onClick={() => setCopyOpen(true)} </>
disabled={currentRules.length === 0} )}
title="Копировать правила на другой сервер" <Button variant="outline" size="sm" onClick={() => setPreviewOpen(true)}>
> <FileCodeIcon className="size-4" />RouterOS
<CopyIcon className="size-4" />Копировать </Button>
</Button> <Button
<Button size="sm" onClick={openCreate}> variant="outline" size="sm"
<PlusIcon className="size-4" />Новое правило onClick={() => setCopyOpen(true)}
</Button> disabled={currentRules.length === 0}
</> title="Копировать правила на другой сервер"
} >
/> <CopyIcon className="size-4" />Копировать
</Button>
{isLive && liveLoadState === "error" && ( <Button size="sm" onClick={openCreate}>
<div className="shrink-0 border-b border-destructive/30 bg-destructive/10 px-6 py-2.5 text-xs text-destructive flex items-center gap-2"> <PlusIcon className="size-4" />Новое правило
<AlertCircleIcon className="size-3.5 shrink-0" /> </Button>
Бекенд недоступен — показаны демо-данные из lib/data. Проверьте URL бекенда в настройках. </>
</div>
)}
{/* ── summary bar + server chips (same pattern as monitoring) ── */}
<div className="border-b bg-muted/20 px-6 py-3 flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">Всего правил</span>
<span className="font-semibold tabular-nums">{totalRules}</span>
</div>
{(() => {
const bhTotal = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
if (bhTotal === 0) return null
return (
<span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded border
bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20">
⊘ {bhTotal} blackhole
</span>
)
})()}
<div className="w-px h-4 bg-border mx-1 shrink-0" />
{allServers.map(s => {
const count = rulesets.find(r => r.serverId === s.id)?.rules.length ?? 0
const active = selectedServerId === s.id
return (
<button key={s.id}
onClick={() => { setSelectedServerId(s.id); setSearch("") }}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
active
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
!s.enabled && !active && "opacity-40",
)}>
<StatusDot status={s.status} />
<Flag code={s.country} size={12} />
<span className="font-mono">{s.name}</span>
<TypeChip type={s.type} />
<span className={cn(
"tabular-nums font-semibold",
active ? "" : count > 0 ? "text-foreground" : "opacity-40",
)}>{count}</span>
</button>
)
})}
</div>
{/* ── toolbar ── */}
<div className="px-6 py-3 flex items-center gap-3 border-b flex-wrap shrink-0">
<div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Community, gateway, описание…"
value={search} onChange={e => setSearch(e.target.value)} />
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
</button>
)}
</div>
<p className="text-xs text-muted-foreground ml-auto">
{filteredRules.length !== currentRules.length
? `${filteredRules.length} из ${currentRules.length} правил`
: `${currentRules.length} правил`
} }
</p> />
</div> }
banner={
{/* ── main content ── */} <>
<div className="flex-1 overflow-y-auto p-6"> {isLive && liveLoadState === "error" && (
<div className="shrink-0 border-b border-destructive/30 bg-destructive/10 px-6 py-2.5 text-xs text-destructive flex items-center gap-2">
<AlertCircleIcon className="size-3.5 shrink-0" />
Бекенд недоступен — показаны демо-данные из lib/data. Проверьте URL бекенда в настройках.
</div>
)}
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
<div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Community, gateway, описание…"
value={search} onChange={e => setSearch(e.target.value)} />
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
</button>
)}
</div>
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">Всего правил</span>
<span className="font-semibold tabular-nums">{totalRules}</span>
</div>
{(() => {
const bhTotal = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
if (bhTotal === 0) return null
return (
<span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded border
bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20">
⊘ {bhTotal} blackhole
</span>
)
})()}
<p className="text-xs text-muted-foreground ml-auto">
{filteredRules.length !== currentRules.length
? `${filteredRules.length} из ${currentRules.length} правил`
: `${currentRules.length} правил`
}
</p>
</div>
</>
}
>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* RouterOS 7.x BGP extensions — только демо из lib/data (моки) */} {/* RouterOS 7.x BGP extensions — только демо из lib/data (моки) */}
@@ -1886,7 +1813,7 @@ export default function FiltersPage() {
</DataPageCard> </DataPageCard>
</div> </div>
</div> </ServerRailLayout>
<RuleSheet <RuleSheet
key={`${sheetMode}-${editingId ?? "new"}-${selectedServerId}`} key={`${sheetMode}-${editingId ?? "new"}-${selectedServerId}`}
@@ -1927,6 +1854,6 @@ export default function FiltersPage() {
recRoutesByServer={recRoutesByServer} recRoutesByServer={recRoutesByServer}
ensureRecursiveFor={ensureRecursiveRoutes} ensureRecursiveFor={ensureRecursiveRoutes}
/> />
</div> </>
) )
} }
File diff suppressed because it is too large. Load diff
+155 -149
View File
@@ -14,7 +14,7 @@ import { requestJson } from "@/shared/api/http-client"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { toast } from "sonner" import { toast } from "sonner"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -26,14 +26,16 @@ import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { Flag } from "@/components/flag"
import { import {
PlusIcon, RefreshCwIcon, MoreHorizontalIcon, PlusIcon, RefreshCwIcon, MoreHorizontalIcon,
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon, LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon, EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon, CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon,
DatabaseIcon, DatabaseIcon,
} from "lucide-react" } from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
// ─── label maps ───────────────────────────────────────────────────────────── // ─── label maps ─────────────────────────────────────────────────────────────
@@ -240,11 +242,11 @@ export default function GrePage() {
const [pageTab, setPageTab] = useState<PageTab>("tunnels") const [pageTab, setPageTab] = useState<PageTab>("tunnels")
const [tabFilter, setTabFilter] = useState<TabFilter>("all") const [tabFilter, setTabFilter] = useState<TabFilter>("all")
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [tunnelOpen, setTunnelOpen] = useState(false) const [tunnelOpen, setTunnelOpen] = useState(false)
const [poolOpen, setPoolOpen] = useState(false) const [poolOpen, setPoolOpen] = useState(false)
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null) const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
const [copied, setCopied] = useState(false)
const [tForm, setTForm] = useState(defaultTunnelForm) const [tForm, setTForm] = useState(defaultTunnelForm)
const [pForm, setPForm] = useState(defaultPoolForm) const [pForm, setPForm] = useState(defaultPoolForm)
@@ -293,6 +295,25 @@ export default function GrePage() {
[isLive, displayTunnels], [isLive, displayTunnels],
) )
const greRailItems = useMemo<ServerTileItem[]>(() => (
displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
enabled: s.enabled,
meta: String(displayTunnels.filter((t) => t.serverId === s.id).length),
}))
), [displayServers, displayTunnels])
const scopedTunnels = useMemo(() => {
if (selectedServerId === ALL_SERVERS_ID) return displayTunnels
return displayTunnels.filter((t) => t.serverId === selectedServerId)
}, [displayTunnels, selectedServerId])
const serverById = useMemo( const serverById = useMemo(
() => Object.fromEntries(displayServers.map((s) => [s.id, s])), () => Object.fromEntries(displayServers.map((s) => [s.id, s])),
[displayServers], [displayServers],
@@ -341,7 +362,7 @@ export default function GrePage() {
}, [dataError]) }, [dataError])
const filtered = useMemo(() => { const filtered = useMemo(() => {
return displayTunnels.filter((t) => { return scopedTunnels.filter((t) => {
if (tabFilter === "up" && t.status !== "up") return false if (tabFilter === "up" && t.status !== "up") return false
if (tabFilter === "ipsec" && !t.ipsec) return false if (tabFilter === "ipsec" && !t.ipsec) return false
if (tabFilter === "plain" && t.ipsec) return false if (tabFilter === "plain" && t.ipsec) return false
@@ -354,60 +375,68 @@ export default function GrePage() {
serverById[t.serverId]?.name.toLowerCase().includes(q) serverById[t.serverId]?.name.toLowerCase().includes(q)
) )
}) })
}, [tabFilter, search, displayTunnels, serverById]) }, [tabFilter, search, scopedTunnels, serverById])
const upCount = displayTunnels.filter((t) => t.status === "up").length const upCount = displayTunnels.filter((t) => t.status === "up").length
const ipsecCount = displayTunnels.filter((t) => t.ipsec).length const ipsecCount = displayTunnels.filter((t) => t.ipsec).length
const scopedUpCount = scopedTunnels.filter((t) => t.status === "up").length
const scopedIpsecCount = scopedTunnels.filter((t) => t.ipsec).length
const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [ const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [
{ value: "all", label: "Все", count: displayTunnels.length }, { value: "all", label: "Все", count: scopedTunnels.length },
{ value: "up", label: "Активные", count: upCount }, { value: "up", label: "Активные", count: scopedUpCount },
{ value: "ipsec", label: "С IPsec", count: ipsecCount }, { value: "ipsec", label: "С IPsec", count: scopedIpsecCount },
{ value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount }, { value: "plain", label: "Без IPsec", count: scopedTunnels.length - scopedIpsecCount },
] ]
function handleCopy(code: string) { const greExportCode = useMemo(
navigator.clipboard.writeText(code).then(() => { () => (codePreviewTunnel ? generateRosCommands(codePreviewTunnel, serverById) : ""),
setCopied(true) [codePreviewTunnel, serverById],
toast.success("Команды скопированы") )
setTimeout(() => setCopied(false), 2000)
})
}
return ( return (
<div className="flex flex-col h-full"> <>
<PageHeader <ServerRailLayout
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]} items={greRailItems}
actions={ selectedId={selectedServerId}
<> onSelect={setSelectedServerId}
<Button showAll
variant="outline" allCount={displayServers.length}
size="sm" loading={isLive && dataLoading && displayServers.length === 0}
onClick={() => { void loadLive() }} header={
disabled={!isLive || dataLoading} <PageHeader
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"} crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
> actions={
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} /> <>
Обновить <ServerRailMobileButton />
</Button> <Button
<Button variant="outline"
variant="outline" size="sm"
size="sm" onClick={() => { void loadLive() }}
onClick={() => { void syncJhToDb() }} disabled={!isLive || dataLoading}
disabled={!isLive || syncJhBusy || dataLoading} title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE" >
> <RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} /> Обновить
JH → БД </Button>
</Button> <Button
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}> variant="outline"
<PlusIcon className="size-4" />Добавить туннель size="sm"
</Button> onClick={() => { void syncJhToDb() }}
</> disabled={!isLive || syncJhBusy || dataLoading}
} title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
/> >
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
<div className="flex-1 overflow-y-auto p-6"> JH → БД
</Button>
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
<PlusIcon className="size-4" />Добавить туннель
</Button>
</>
}
/>
}
>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{/* Legacy banner */} {/* Legacy banner */}
@@ -424,27 +453,39 @@ export default function GrePage() {
</div> </div>
</div> </div>
{/* Stats */} <KpiStatGrid
<div className="grid grid-cols-4 gap-4"> aria-label="Сводка GRE"
{[ items={[
{ label: "Всего туннелей", value: displayTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> }, {
{ label: "Активно", value: upCount, icon: <ShieldCheckIcon className="size-4 text-emerald-500" /> }, id: "tunnels",
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> }, label: "Всего туннелей",
{ label: "IP-пулов", value: displayPools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> }, value: displayTunnels.length,
].map((s) => ( icon: <NetworkIcon className="size-4" />,
<Frame key={s.label} className="h-full"> iconClassName: "text-muted-foreground",
<FramePanel className="relative isolate flex h-full items-start gap-3"> },
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground"> {
{s.icon} id: "up",
</IconTile> label: "Активно",
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> value: upCount,
<p className="text-muted-foreground text-sm font-medium">{s.label}</p> icon: <ShieldCheckIcon className="size-4" />,
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p> iconClassName: "text-success",
</div> },
</FramePanel> {
</Frame> id: "ipsec",
))} label: "Защищены IPsec",
</div> value: ipsecCount,
icon: <LockIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "pools",
label: "IP-пулов",
value: displayPools.length,
icon: <DatabaseIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{/* Page tabs */} {/* Page tabs */}
<div className="flex items-center gap-1 border-b"> <div className="flex items-center gap-1 border-b">
@@ -548,85 +589,50 @@ export default function GrePage() {
</div> </div>
</OpsPanel> </OpsPanel>
</div> </div>
</div> </ServerRailLayout>
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */} {/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
<Sheet open={!!codePreviewTunnel} onOpenChange={(open) => { if (!open) setCodePreviewTunnel(null) }}> <CodeExportSheet
<SheetContent side="right" className="w-full sm:max-w-2xl flex flex-col gap-0 p-0"> open={!!codePreviewTunnel}
{codePreviewTunnel && (() => { onClose={() => setCodePreviewTunnel(null)}
const code = generateRosCommands(codePreviewTunnel, serverById) title={codePreviewTunnel?.name ?? "GRE"}
return ( description="Команды RouterOS 7.20+ для создания туннеля"
<> formats={[
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0"> {
<div className="flex items-start justify-between gap-4"> id: "rsc",
<div> label: "RouterOS",
<SheetTitle className="font-mono">{codePreviewTunnel.name}</SheetTitle> filename: `${codePreviewTunnel?.name ?? "gre"}.rsc`,
<SheetDescription>Команды RouterOS 7.20+ для создания туннеля</SheetDescription> code: greExportCode,
</div> },
<Button ]}
variant="outline" size="sm" beforeCode={
className="shrink-0 gap-1.5" codePreviewTunnel ? (
onClick={() => handleCopy(code)} <div className="flex flex-wrap gap-3 text-xs shrink-0">
> <span className="flex items-center gap-1.5">
{copied <span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
? <><CheckIcon className="size-3.5 text-emerald-500" /> Скопировано</> {STATUS_MAP[codePreviewTunnel.status].label}
: <><CopyIcon className="size-3.5" /> Копировать</>} </span>
</Button> <span className="text-muted-foreground">·</span>
</div> <span>{serverById[codePreviewTunnel.serverId]?.name}</span>
</SheetHeader> <span className="text-muted-foreground">·</span>
<span className="font-mono">
<div className="flex-1 overflow-y-auto"> {codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress}
{/* meta strip */} {" → "}
<div className="flex flex-wrap gap-3 px-6 py-3 border-b bg-muted/30 text-xs"> {codePreviewTunnel.remoteAddress}
<span className="flex items-center gap-1.5"> </span>
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} /> {codePreviewTunnel.ipsec ? (
{STATUS_MAP[codePreviewTunnel.status].label} <>
</span> <span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">·</span> <span className="flex items-center gap-1 text-success">
<span>{serverById[codePreviewTunnel.serverId]?.name}</span> <LockIcon className="size-3" />
<span className="text-muted-foreground">·</span> IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}
<span className="font-mono">{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress} → {codePreviewTunnel.remoteAddress}</span> </span>
{codePreviewTunnel.ipsec && ( </>
<> ) : null}
<span className="text-muted-foreground">·</span> </div>
<span className="flex items-center gap-1 text-emerald-400"><LockIcon className="size-3" /> IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}</span> ) : null
</> }
)} />
</div>
{/* code block */}
<pre className="px-6 py-5 text-xs font-mono leading-relaxed text-foreground/90 whitespace-pre overflow-x-auto select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isSection = isComment && line.includes("──")
const isKey = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isSection ? "text-muted-foreground/60"
: isComment ? "text-muted-foreground"
: isKey ? "text-sky-400/90"
: "text-foreground"
}>
{line}
{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1 gap-1.5" onClick={() => handleCopy(code)}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать команды"}
</Button>
</SheetFooter>
</>
)
})()}
</SheetContent>
</Sheet>
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */} {/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}> <Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
@@ -839,6 +845,6 @@ export default function GrePage() {
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
</div> </>
) )
} }
+150 -133
View File
@@ -7,17 +7,20 @@ import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-da
import { OspfRoutesDataGrid, routeTypeClass } from "@/components/data-grids/ospf-routes-data-grid" import { OspfRoutesDataGrid, routeTypeClass } from "@/components/data-grids/ospf-routes-data-grid"
import { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid" import { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon, RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon,
NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon, NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon,
LayersIcon, RouterIcon, UsersIcon, CheckCircleIcon, AlertCircleIcon,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
import { Flag } from "@/components/flag" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data" import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
// ─── types ──────────────────────────────────────────────────────────────────── // ─── types ────────────────────────────────────────────────────────────────────
@@ -707,7 +710,7 @@ function InterfacesTab({
}, [grouped, isLive]) }, [grouped, isLive])
const needsOptimize = !isLive && items.some(item => hints[item.key] && hints[item.key].optimalCost !== item.cost) const needsOptimize = !isLive && items.some(item => hints[item.key] && hints[item.key].optimalCost !== item.cost)
const canOptimizeLive = isLive && filterServerId !== "all" const canOptimizeLive = isLive && filterServerId !== ALL_SERVERS_ID
const uniqueLiveFallbackOpt = useMemo(() => { const uniqueLiveFallbackOpt = useMemo(() => {
const out: Record<string, number> = {} const out: Record<string, number> = {}
const byRouter: Record<string, OspfItem[]> = {} const byRouter: Record<string, OspfItem[]> = {}
@@ -921,20 +924,33 @@ function NeighborsTab({
return ( return (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="grid grid-cols-3 gap-3"> <KpiStatGrid
{[ aria-label="Сводка соседей OSPF"
{ label: "Всего соседей", value: neighbors.length, color: "" }, items={[
{ label: "Full", value: fullCount, color: "text-[var(--status-online-fg)]" }, {
{ label: "Не Full", value: neighbors.length - fullCount, color: neighbors.length - fullCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" }, id: "neighbors",
].map(s => ( label: "Всего соседей",
<Frame key={s.label} className="h-full"> value: neighbors.length,
<FramePanel className="flex flex-col gap-0.5"> icon: <UsersIcon className="size-4" />,
<p className="text-muted-foreground text-sm font-medium">{s.label}</p> iconClassName: "text-muted-foreground",
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p> },
</FramePanel> {
</Frame> id: "full",
))} label: "Full",
</div> value: fullCount,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "not-full",
label: "Не Full",
value: neighbors.length - fullCount,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: neighbors.length - fullCount > 0 ? "text-warning" : "text-muted-foreground",
variant: neighbors.length - fullCount > 0 ? "warning" : "default",
},
]}
/>
{graphNodes.length > 0 && ( {graphNodes.length > 0 && (
<div className="flex rounded-xl overflow-hidden border border-white/[0.06]"> <div className="flex rounded-xl overflow-hidden border border-white/[0.06]">
@@ -1044,21 +1060,41 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> <KpiStatGrid
{[ aria-label="Сводка BFD"
{ label: "Сессий BFD", value: sessions.length, color: "" }, items={[
{ label: "Up", value: upCount, color: "text-[var(--status-online-fg)]" }, {
{ label: "Down / Admin", value: downCount, color: downCount > 0 ? "text-[var(--status-offline-fg)]" : "text-muted-foreground" }, id: "sessions",
{ label: "Init / другие", value: initCount, color: initCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" }, label: "Сессий BFD",
].map(s => ( value: sessions.length,
<Frame key={s.label} className="h-full"> icon: <ActivityIcon className="size-4" />,
<FramePanel className="flex flex-col gap-0.5"> iconClassName: "text-muted-foreground",
<p className="text-muted-foreground text-sm font-medium">{s.label}</p> },
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p> {
</FramePanel> id: "up",
</Frame> label: "Up",
))} value: upCount,
</div> icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "down",
label: "Down / Admin",
value: downCount,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: downCount > 0 ? "text-destructive" : "text-muted-foreground",
variant: downCount > 0 ? "destructive" : "default",
},
{
id: "init",
label: "Init / другие",
value: initCount,
icon: <LayersIcon className="size-4" />,
iconClassName: initCount > 0 ? "text-warning" : "text-muted-foreground",
variant: initCount > 0 ? "warning" : "default",
},
]}
/>
{sessions.length === 0 && ( {sessions.length === 0 && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-8 text-center text-sm text-muted-foreground"> <div className="rounded-md border border-border bg-muted/30 px-4 py-8 text-center text-sm text-muted-foreground">
@@ -1096,7 +1132,7 @@ const TABS: Array<{ id: OspfTab; label: string; icon: React.ReactNode }> = [
export default function OspfPage() { export default function OspfPage() {
const [activeTab, setActiveTab] = useState<OspfTab>("interfaces") const [activeTab, setActiveTab] = useState<OspfTab>("interfaces")
const [filterServerId, setFilterServerId] = useState<string>("all") const [filterServerId, setFilterServerId] = useState<string>(ALL_SERVERS_ID)
const { mode, backendUrl } = useDataSource() const { mode, backendUrl } = useDataSource()
const isLive = mode === "live" const isLive = mode === "live"
@@ -1218,9 +1254,26 @@ export default function OspfPage() {
}, [items, neighbors, bfdSessions]) }, [items, neighbors, bfdSessions])
// ── filtered display data ───────────────────────────────────────────────────── // ── filtered display data ─────────────────────────────────────────────────────
const displayItems = filterServerId === "all" ? items : items.filter(i => i.routerKey === filterServerId) const displayItems = filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId)
const displayNeighbors = filterServerId === "all" ? neighbors : neighbors.filter(n => n.localRouter === filterServerId) const displayNeighbors = filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
const displayBfdSessions = filterServerId === "all" ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId) const displayBfdSessions = filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
const ospfRailItems = useMemo<ServerTileItem[]>(() => (
ospfServers.map((s) => {
const counts = serverCounts[s.id]
const host = s.label.replace(/^mt-/, "")
return {
id: s.id,
name: host,
host,
site: s.site,
country: s.country || undefined,
meta: counts
? `${counts.neighbors}n · ${counts.ifaces}i${counts.bfd > 0 ? ` · ${counts.bfd}b` : ""}`
: undefined,
}
})
), [ospfServers, serverCounts])
// Graph always shows full topology (highlight is handled by node click inside tab) // Graph always shows full topology (highlight is handled by node click inside tab)
// KPIs reflect the current filter // KPIs reflect the current filter
@@ -1229,91 +1282,45 @@ export default function OspfPage() {
const totalAreas = new Set(displayItems.map(i => i.area)).size const totalAreas = new Set(displayItems.map(i => i.area)).size
return ( return (
<div className="flex flex-col h-full"> <ServerRailLayout
<PageHeader items={ospfRailItems}
crumbs={[{ label: "Инструменты" }, { label: "OSPF" }]} selectedId={filterServerId}
actions={ onSelect={setFilterServerId}
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}> showAll
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} /> allCount={ospfServers.length}
Обновить loading={isLive && loading && ospfServers.length === 0}
</Button> header={
} <PageHeader
/> crumbs={[{ label: "Инструменты" }, { label: "OSPF" }]}
actions={
<div className="border-b bg-background shrink-0"> <>
<div className="flex items-center px-6"> <ServerRailMobileButton />
{TABS.map(t => ( <Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
<button key={t.id} onClick={() => setActiveTab(t.id)} <RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
className={cn( Обновить
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px", </Button>
activeTab === t.id </>
? "border-primary text-foreground" }
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border", />
)}> }
{t.icon}{t.label} banner={
</button> <div className="border-b bg-background shrink-0">
))} <div className="flex items-center px-6">
</div> {TABS.map(t => (
</div> <button key={t.id} onClick={() => setActiveTab(t.id)}
{/* ── server filter chips (same pattern as Filters page) ─────────────── */}
{ospfServers.length > 0 && (
<div className="border-b bg-muted/20 px-6 py-2.5 flex items-center gap-2 flex-wrap shrink-0">
{/* "All" chip */}
<button
onClick={() => setFilterServerId("all")}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
filterServerId === "all"
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
)}>
Все серверы
<span className={cn(
"tabular-nums font-semibold",
filterServerId === "all" ? "" : "text-foreground/60",
)}>{items.length}</span>
</button>
<div className="w-px h-4 bg-border shrink-0" />
{ospfServers.map(s => {
const counts = serverCounts[s.id]
const active = filterServerId === s.id
return (
<button key={s.id} onClick={() => setFilterServerId(s.id)}
className={cn( className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all", "flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
active activeTab === t.id
? "bg-foreground text-background border-foreground" ? "border-primary text-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40", : "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
)}> )}>
{s.country ? <Flag code={s.country} size={12} /> : null} {t.icon}{t.label}
{s.site && (
<span className={cn(
"inline-block px-1 py-0 rounded text-[9px] font-bold leading-4",
active
? "bg-white/20"
: "bg-muted-foreground/15 text-foreground/70",
)}>{s.site}</span>
)}
<span className="font-mono">{s.label.replace(/^mt-/, "")}</span>
{counts && (
<span className={cn(
"tabular-nums text-[10px]",
active ? "opacity-80" : "text-foreground/50",
)}>
{counts.neighbors}n · {counts.ifaces}i
{counts.bfd > 0 && ` · ${counts.bfd}b`}
</span>
)}
</button> </button>
) ))}
})} </div>
</div> </div>
)} }
>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{/* data source banner */} {/* data source banner */}
@@ -1346,21 +1353,32 @@ export default function OspfPage() {
</span> </span>
)} )}
{/* KPI strip */} <KpiStatGrid
<div className="grid grid-cols-3 gap-3"> aria-label="Сводка OSPF"
{[ items={[
{ label: "Роутеров", value: totalRouters }, {
{ label: "Интерфейсов", value: totalInterfaces }, id: "routers",
{ label: "Зон (Area)", value: totalAreas }, label: "Роутеров",
].map(s => ( value: totalRouters,
<Frame key={s.label} className="h-full"> icon: <RouterIcon className="size-4" />,
<FramePanel className="flex flex-col gap-0.5"> iconClassName: "text-muted-foreground",
<p className="text-muted-foreground text-sm font-medium">{s.label}</p> },
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p> {
</FramePanel> id: "ifaces",
</Frame> label: "Интерфейсов",
))} value: totalInterfaces,
</div> icon: <NetworkIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "areas",
label: "Зон (Area)",
value: totalAreas,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{activeTab === "interfaces" && ( {activeTab === "interfaces" && (
<InterfacesTab <InterfacesTab
@@ -1384,7 +1402,6 @@ export default function OspfPage() {
{activeTab === "bfd" && <BfdTab sessions={displayBfdSessions} />} {activeTab === "bfd" && <BfdTab sessions={displayBfdSessions} />}
</div> </div>
</div> </ServerRailLayout>
</div>
) )
} }
+53 -32
View File
@@ -17,6 +17,8 @@ import {
} from "@/components/data-grids/probes-speed-probes-data-grid" } from "@/components/data-grids/probes-speed-probes-data-grid"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { type ServerTileItem } from "@/components/server-tile-rail"
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data" import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { import {
@@ -480,19 +482,25 @@ function ScheduleTab({
setRules, setRules,
serverOptions, serverOptions,
tunnelsForServer, tunnelsForServer,
defaultSrc,
}: { }: {
rules: SchedRule[] rules: SchedRule[]
setRules: React.Dispatch<React.SetStateAction<SchedRule[]>> setRules: React.Dispatch<React.SetStateAction<SchedRule[]>>
serverOptions: Server[] serverOptions: Server[]
tunnelsForServer: (serverId: string) => GreTunnel[] tunnelsForServer: (serverId: string) => GreTunnel[]
defaultSrc?: string
}) { }) {
const [showAdd, setShowAdd] = useState(false) const [showAdd, setShowAdd] = useState(false)
const [addSrc, setAddSrc] = useState(serverOptions[0]?.id ?? "srv1") const [addSrc, setAddSrc] = useState(defaultSrc ?? serverOptions[0]?.id ?? "srv1")
const [addTun, setAddTun] = useState("") const [addTun, setAddTun] = useState("")
const [addType, setAddType] = useState<SchedType>("ping") const [addType, setAddType] = useState<SchedType>("ping")
const [addMin, setAddMin] = useState(10) const [addMin, setAddMin] = useState(10)
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer]) const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
useEffect(() => {
if (defaultSrc) setAddSrc(defaultSrc)
}, [defaultSrc])
useEffect(() => { useEffect(() => {
const list = tunnelsForServer(addSrc) const list = tunnelsForServer(addSrc)
if (list.length && !list.some(t => t.id === addTun)) { if (list.length && !list.some(t => t.id === addTun)) {
@@ -630,6 +638,20 @@ export default function ProbesPage() {
return liveServers return liveServers
}, [isLive, liveServers]) }, [isLive, liveServers])
const probeRailItems = useMemo<ServerTileItem[]>(() => (
allServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
enabled: s.enabled,
selectable: s.enabled,
}))
), [allServers])
useEffect(() => { useEffect(() => {
if (!isLive) { if (!isLive) {
setRosSrcV4(undefined) setRosSrcV4(undefined)
@@ -887,24 +909,33 @@ export default function ProbesPage() {
} }
return ( return (
<div className="flex flex-col h-full"> <ServerRailLayout
<PageHeader items={probeRailItems}
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]} selectedId={srcId}
actions={ onSelect={setSrcId}
running.length > 0 showAll={false}
? <Button variant="outline" size="sm" onClick={() => { loading={isLive && liveLoad === "loading" && allServers.length === 0}
liveProbeRunRef.current?.ctrl.abort() header={
setTests(p => p.map(t => (t.status === "running" <PageHeader
? { ...t, status: "done" as const, totalLines: t.lines.length } crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
: t))) actions={
}}> <>
<SquareIcon className="size-4" />Остановить все <ServerRailMobileButton />
</Button> {running.length > 0
: undefined ? <Button variant="outline" size="sm" onClick={() => {
} liveProbeRunRef.current?.ctrl.abort()
/> setTests(p => p.map(t => (t.status === "running"
? { ...t, status: "done" as const, totalLines: t.lines.length }
<div className="flex-1 overflow-y-auto p-6"> : t)))
}}>
<SquareIcon className="size-4" />Остановить все
</Button>
: null}
</>
}
/>
}
>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{isLive && liveLoad === "error" && ( {isLive && liveLoad === "error" && (
@@ -916,7 +947,7 @@ export default function ProbesPage() {
{isLive && ( {isLive && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Режим «Живые»: ping/traceroute/route/mtu/bandwidth выполняются на выбранном MikroTik; для «Источника» поле src-address — только IPv4 (FQDN резолвится на бекенде). Traceroute через REST: у MikroTik лимит сессии ~60 с (параметры команды это не продлевают); у нас timeout в формате HH:MM:SS, count=1, max-hops при необходимости уменьшается автоматически. «Стоп» прерывает HTTP к бекенду. DNS — резолвер приложения, не MikroTik. Режим «Живые»: ping/traceroute/route/mtu/bandwidth выполняются на выбранном MikroTik; для источника поле src-address — только IPv4 (FQDN резолвится на бекенде). Traceroute через REST: у MikroTik лимит сессии ~60 с (параметры команды это не продлевают); у нас timeout в формате HH:MM:SS, count=1, max-hops при необходимости уменьшается автоматически. «Стоп» прерывает HTTP к бекенду. DNS — резолвер приложения, не MikroTik.
</p> </p>
)} )}
@@ -945,16 +976,6 @@ export default function ProbesPage() {
{/* main config row */} {/* main config row */}
<div className="flex flex-wrap items-end gap-3"> <div className="flex flex-wrap items-end gap-3">
{/* source server */}
<div>
<OptionLabel>Источник</OptionLabel>
<NativeSelect value={srcId} onChange={setSrcId} className="min-w-[175px]">
{allServers.filter(s => s.enabled).map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</NativeSelect>
</div>
{/* target — all tools except bandwidth */} {/* target — all tools except bandwidth */}
{tool !== "bandwidth" && ( {tool !== "bandwidth" && (
<div className="flex-1 min-w-[140px]"> <div className="flex-1 min-w-[140px]">
@@ -1169,13 +1190,13 @@ export default function ProbesPage() {
setRules={setRules} setRules={setRules}
serverOptions={allServers} serverOptions={allServers}
tunnelsForServer={sid => greTunnels.filter(t => t.serverId === sid)} tunnelsForServer={sid => greTunnels.filter(t => t.serverId === sid)}
defaultSrc={srcId}
/> />
) )
)} )}
</div> </div>
</div> </div>
</div> </div>
</div> </ServerRailLayout>
</div>
) )
} }
+72 -76
View File
@@ -20,6 +20,8 @@ import { cn } from "@/lib/utils"
import { servers as mockServers, type Server } from "@/lib/data" import { servers as mockServers, type Server } from "@/lib/data"
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react" import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { type ServerTileItem } from "@/components/server-tile-rail"
interface BackendServer { interface BackendServer {
id: number id: number
@@ -543,6 +545,18 @@ export default function RecursiveRoutesPage() {
} }
const currentServer = servers.find(s => s.id === selectedServerId) const currentServer = servers.find(s => s.id === selectedServerId)
const rrRailItems = useMemo<ServerTileItem[]>(() => (
servers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
enabled: s.enabled,
}))
), [servers])
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase() const q = search.trim().toLowerCase()
if (!q) return rows if (!q) return rows
@@ -574,82 +588,64 @@ export default function RecursiveRoutesPage() {
}, [filteredRows]) }, [filteredRows])
return ( return (
<div className="flex h-full flex-col"> <>
<PageHeader <ServerRailLayout
crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]} items={rrRailItems}
actions={ selectedId={selectedServerId}
<> onSelect={setSelectedServerId}
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}> showAll={false}
{busy === "from" ? "Синхронизация..." : "Router => DB"} showCount={false}
</Button> loading={isLive && !liveServerListReady}
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}> header={
{busy === "to" ? "Применение..." : "DB => Router"} <PageHeader
</Button> crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]}
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}> actions={
<SaveIcon className="size-4" />Сохранить в БД <>
</Button> <ServerRailMobileButton />
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}> <Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
<PlusIcon className="size-4" />Добавить {busy === "from" ? "Синхронизация..." : "Router => DB"}
</Button> </Button>
</> <Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}>
} {busy === "to" ? "Применение..." : "DB => Router"}
/> </Button>
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}>
<div className="border-b bg-muted/20 px-6 py-3 flex items-center gap-3 flex-wrap"> <SaveIcon className="size-4" />Сохранить в БД
<div className="flex items-center gap-1.5 text-xs"> </Button>
<span className="text-muted-foreground">Всего маршрутов</span> <Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
<span className="font-semibold tabular-nums">{rows.length}</span> <PlusIcon className="size-4" />Добавить
</div> </Button>
<div className="w-px h-4 bg-border mx-1 shrink-0" /> </>
{servers.map((s) => { }
const count = s.id === selectedServerId ? rows.length : 0 />
const active = selectedServerId === s.id }
return ( banner={
<button key={s.id} <div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
onClick={() => setSelectedServerId(s.id)} <div className="relative min-w-[200px] max-w-xs flex-1">
className={cn( <SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all", <Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
active value={search} onChange={(e) => setSearch(e.target.value)} />
? "bg-foreground text-background border-foreground" {search && (
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40", <button onClick={() => setSearch("")}
!s.enabled && !active && "opacity-40", className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
)}> <XIcon className="size-3.5" />
<StatusDot status={s.status} /> </button>
<Flag code={s.country} size={12} /> )}
<span className="font-mono">{s.name}</span> </div>
<TypeChip type={s.type} /> <div className="flex items-center gap-1.5 text-xs">
<span className={cn( <span className="text-muted-foreground">Всего маршрутов</span>
"tabular-nums font-semibold", <span className="font-semibold tabular-nums">{rows.length}</span>
active ? "" : count > 0 ? "text-foreground" : "opacity-40", </div>
)}>{count}</span> <p className="text-xs text-muted-foreground ml-auto">
</button> {filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
) </p>
})} {opError && (
</div> <div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{opError}
<div className="px-6 py-3 flex items-center gap-3 border-b flex-wrap shrink-0"> </div>
<div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
value={search} onChange={(e) => setSearch(e.target.value)} />
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
</button>
)} )}
</div> </div>
<p className="text-xs text-muted-foreground ml-auto"> }
{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`} >
</p>
{opError && (
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{opError}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-6">
{!isLive ? ( {!isLive ? (
<Frame dense className="w-full"> <Frame dense className="w-full">
<FramePanel className="p-6 text-sm text-muted-foreground"> <FramePanel className="p-6 text-sm text-muted-foreground">
@@ -692,7 +688,7 @@ export default function RecursiveRoutesPage() {
</button> </button>
</DataPageCard> </DataPageCard>
)} )}
</div> </ServerRailLayout>
<RouteSheet <RouteSheet
open={sheetOpen} open={sheetOpen}
@@ -702,6 +698,6 @@ export default function RecursiveRoutesPage() {
onClose={() => setSheetOpen(false)} onClose={() => setSheetOpen(false)}
gateways={gatewayOptions} gateways={gatewayOptions}
/> />
</div> </>
) )
} }
+23 -29
View File
@@ -10,7 +10,7 @@ import { RouteOptimizerCommRecsDataGrid } from "@/components/data-grids/route-op
import { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid" import { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid"
import { FormToggle } from "@/components/form-kit" import { FormToggle } from "@/components/form-kit"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -589,32 +589,25 @@ export default function RouteOptimizerPage() {
label: "Home роутеров", label: "Home роутеров",
value: homeCount, value: homeCount,
sub: `${wanCount} WAN-аплинков`, sub: `${wanCount} WAN-аплинков`,
icon: <MonitorIcon className="size-4 text-muted-foreground" />, icon: <MonitorIcon className="size-4" />,
}, },
{ {
label: "JumpHost", label: "JumpHost",
value: jh.length, value: jh.length,
sub: jhSub, sub: jhSub,
icon: <ServerIcon className="size-4 text-violet-400" />, icon: <ServerIcon className="size-4" />,
}, },
{ {
label: "Exit Node", label: "Exit Node",
value: ex.length, value: ex.length,
sub: exSub, sub: exSub,
icon: <NetworkIcon className="size-4 text-emerald-500" />, icon: <NetworkIcon className="size-4" />,
}, },
{ {
label: "Переключений", label: "Переключений",
value: totalSwitches, value: totalSwitches,
sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально", sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально",
icon: ( icon: <ZapIcon className="size-4" />,
<ZapIcon
className={cn(
"size-4",
totalSwitches > 0 ? "text-amber-500" : "text-muted-foreground",
)}
/>
),
}, },
] ]
}, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches]) }, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches])
@@ -762,23 +755,24 @@ export default function RouteOptimizerPage() {
)} )}
</div> </div>
{/* Stats chips */} <KpiStatGrid
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> aria-label="Сводка оптимизатора"
{statsChips.map((s) => ( items={statsChips.map((s, i) => ({
<Frame key={s.label} className="h-full"> id: `ro-${i}`,
<FramePanel className="relative isolate flex h-full items-start gap-3"> label: s.label,
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground"> value: s.value,
{s.icon} hint: s.sub,
</IconTile> icon: s.icon,
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> iconClassName: s.label === "Переключений" && totalSwitches > 0
<p className="text-muted-foreground text-sm font-medium">{s.label}</p> ? "text-warning"
<p className="text-xl leading-none font-bold tabular-nums">{s.value}</p> : s.label === "Exit Node"
<p className="text-[10px] text-muted-foreground">{s.sub}</p> ? "text-success"
</div> : s.label === "JumpHost"
</FramePanel> ? "text-primary"
</Frame> : "text-muted-foreground",
))} variant: s.label === "Переключений" && totalSwitches > 0 ? "warning" as const : "default" as const,
</div> }))}
/>
{error && ( {error && (
<div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive"> <div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
+34 -23
View File
@@ -27,8 +27,7 @@ import {
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Frame, FramePanel } from "@/components/reui/frame" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { IconTile } from "@/components/reui/icon-tile"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -491,27 +490,39 @@ export default function ServersPage() {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{/* Stats */} <KpiStatGrid
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4"> aria-label="Сводка серверов"
{[ items={[
{ label: "Всего серверов", value: counts.all, icon: <ServerIcon className="size-4" />, iconClass: "text-muted-foreground" }, {
{ label: "Онлайн", value: counts.online, icon: <CheckCircleIcon className="size-4" />, iconClass: "text-[var(--status-online-fg)]" }, id: "all",
{ label: "JH + Exit Node", value: counts["jump-host"] + counts["exit-node"], icon: <NetworkIcon className="size-4" />, iconClass: "text-muted-foreground" }, label: "Всего серверов",
{ label: "Home Router", value: counts["home-router"],icon: <HomeIcon className="size-4" />, iconClass: "text-muted-foreground" }, value: counts.all,
].map(s => ( icon: <ServerIcon className="size-4" />,
<Frame key={s.label} className="h-full"> iconClassName: "text-muted-foreground",
<FramePanel className="relative isolate flex h-full items-start gap-3"> },
<IconTile variant="elevated" aria-hidden="true" className={cn("size-10.5", s.iconClass)}> {
{s.icon} id: "online",
</IconTile> label: "Онлайн",
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> value: counts.online,
<p className="text-muted-foreground text-sm font-medium">{s.label}</p> icon: <CheckCircleIcon className="size-4" />,
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p> iconClassName: "text-success",
</div> },
</FramePanel> {
</Frame> id: "jh-en",
))} label: "JH + Exit Node",
</div> value: counts["jump-host"] + counts["exit-node"],
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "home",
label: "Home Router",
value: counts["home-router"],
icon: <HomeIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
]}
/>
{/* Table */} {/* Table */}
<DataPageCard> <DataPageCard>
+80 -58
View File
@@ -16,6 +16,17 @@ import { Separator } from "@/components/ui/separator"
import { import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
} from "@/components/ui/sheet" } from "@/components/ui/sheet"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot" import { StatusDot } from "@/components/status-dot"
import { servers } from "@/lib/data" import { servers } from "@/lib/data"
@@ -152,7 +163,7 @@ const PERM_OPTS: { v: PermLevel; label: string }[] = [
const PERM_COLOR: Record<PermLevel, string> = { const PERM_COLOR: Record<PermLevel, string> = {
none: "bg-muted text-muted-foreground", none: "bg-muted text-muted-foreground",
read: "bg-sky-500/10 text-sky-600 dark:text-sky-400", read: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
write: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", write: "bg-success/10 text-success",
} }
const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const
@@ -188,8 +199,8 @@ function PermPills({ value, onChange, disabled }: { value: PermLevel; onChange:
"px-2 text-[11px] font-medium transition-colors", "px-2 text-[11px] font-medium transition-colors",
i < PERM_OPTS.length - 1 && "border-r border-input", i < PERM_OPTS.length - 1 && "border-r border-input",
value === o.v value === o.v
? o.v === "write" ? "bg-emerald-600 text-white dark:bg-emerald-500" ? o.v === "write" ? "bg-success text-white"
: o.v === "read" ? "bg-sky-600 text-white dark:bg-sky-500" : o.v === "read" ? "bg-info text-white"
: "bg-muted-foreground/70 text-white" : "bg-muted-foreground/70 text-white"
: "text-muted-foreground hover:bg-muted", : "text-muted-foreground hover:bg-muted",
)} )}
@@ -685,68 +696,79 @@ function UserSheet({ open, user, onSave, onClose }: {
// ─── delete confirm ─────────────────────────────────────────────────────────── // ─── delete confirm ───────────────────────────────────────────────────────────
function DatabaseRestoreConfirm({ function DatabaseRestoreConfirm({
open,
filename, filename,
busy, busy,
onConfirm, onConfirm,
onCancel, onCancel,
}: { }: {
open: boolean
filename: string filename: string
busy?: boolean busy?: boolean
onConfirm: () => void onConfirm: () => void
onCancel: () => void onCancel: () => void
}) { }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <AlertDialog open={open} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onCancel} /> <AlertDialogContent size="default">
<div className="relative z-10 w-full max-w-sm mx-4 bg-card rounded-xl border shadow-2xl p-5 flex flex-col gap-4"> <AlertDialogHeader>
<div className="flex items-center gap-3"> <AlertDialogMedia className="bg-destructive/10 text-destructive">
<div className="size-9 rounded-full bg-destructive/10 flex items-center justify-center shrink-0"> <AlertCircleIcon />
<AlertCircleIcon className="size-4 text-destructive" /> </AlertDialogMedia>
</div> <AlertDialogTitle>Восстановить базу приложения?</AlertDialogTitle>
<div> <AlertDialogDescription>
<p className="text-sm font-semibold">Восстановить базу приложения?</p> Файл: {filename}. Текущие данные SQLite на бекенде будут полностью заменены.
<p className="text-xs text-muted-foreground mt-0.5 break-all">{filename}</p> Рекомендуется сначала скачать актуальный бэкап.
</div> </AlertDialogDescription>
</div> </AlertDialogHeader>
<p className="text-xs text-muted-foreground"> <AlertDialogFooter>
Текущие данные SQLite на бекенде будут полностью заменены содержимым файла. Рекомендуется сначала скачать <AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
актуальный бэкап. <AlertDialogAction
</p> variant="destructive"
<div className="flex gap-2"> disabled={busy}
<Button variant="outline" className="flex-1" onClick={onCancel} disabled={busy}>Отмена</Button> onClick={onConfirm}
<Button variant="destructive" className="flex-1" onClick={onConfirm} disabled={busy}> >
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null} {busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
{busy ? "Восстановление…" : "Восстановить"} {busy ? "Восстановление…" : "Восстановить"}
</Button> </AlertDialogAction>
</div> </AlertDialogFooter>
</div> </AlertDialogContent>
</div> </AlertDialog>
) )
} }
function DeleteConfirm({ user, onConfirm, onCancel }: { user: User; onConfirm: () => void; onCancel: () => void }) { function DeleteConfirm({
open,
user,
onConfirm,
onCancel,
}: {
open: boolean
user: User
onConfirm: () => void
onCancel: () => void
}) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <AlertDialog open={open} onOpenChange={(v) => { if (!v) onCancel() }}>
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onCancel} /> <AlertDialogContent size="default">
<div className="relative z-10 w-full max-w-sm mx-4 bg-card rounded-xl border shadow-2xl p-5 flex flex-col gap-4"> <AlertDialogHeader>
<div className="flex items-center gap-3"> <AlertDialogMedia className="bg-destructive/10 text-destructive">
<div className="size-9 rounded-full bg-destructive/10 flex items-center justify-center shrink-0"> <TrashIcon />
<TrashIcon className="size-4 text-destructive" /> </AlertDialogMedia>
</div> <AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
<div> <AlertDialogDescription>
<p className="text-sm font-semibold">Удалить пользователя?</p> {user.name} · @{user.login}. Это действие нельзя отменить.
<p className="text-xs text-muted-foreground mt-0.5">{user.name} · @{user.login}</p> Пользователь потеряет доступ немедленно.
</div> </AlertDialogDescription>
</div> </AlertDialogHeader>
<p className="text-xs text-muted-foreground"> <AlertDialogFooter>
Это действие нельзя отменить. Пользователь потеряет доступ немедленно. <AlertDialogCancel onClick={onCancel}>Отмена</AlertDialogCancel>
</p> <AlertDialogAction variant="destructive" onClick={onConfirm}>
<div className="flex gap-2"> Удалить
<Button variant="outline" className="flex-1" onClick={onCancel}>Отмена</Button> </AlertDialogAction>
<Button variant="destructive" className="flex-1" onClick={onConfirm}>Удалить</Button> </AlertDialogFooter>
</div> </AlertDialogContent>
</div> </AlertDialog>
</div>
) )
} }
@@ -937,7 +959,7 @@ export default function SettingsPage() {
// ── Общие ── // ── Общие ──
if (section === "Общие") return ( if (section === "Общие") return (
<div className="space-y-4"> <div className="flex flex-col gap-4">
{/* ── Источник данных ── */} {/* ── Источник данных ── */}
<OpsPanel <OpsPanel
@@ -960,9 +982,7 @@ export default function SettingsPage() {
"px-3 text-xs transition-colors border-input", "px-3 text-xs transition-colors border-input",
i === 0 && "border-r", i === 0 && "border-r",
mode === v mode === v
? v === "live" ? "bg-primary text-primary-foreground"
? "bg-emerald-600 text-white dark:bg-emerald-500"
: "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted", : "text-muted-foreground hover:bg-muted",
)}> )}>
{l} {l}
@@ -972,7 +992,7 @@ export default function SettingsPage() {
</SettingRow> </SettingRow>
) : ( ) : (
<SettingRow label="Режим данных" description="В Docker-образе приложение работает только с живыми данными бекенда"> <SettingRow label="Режим данных" description="В Docker-образе приложение работает только с живыми данными бекенда">
<span className="inline-flex h-8 items-center rounded-md border border-input bg-emerald-600 px-3 text-xs text-white dark:bg-emerald-500"> <span className="inline-flex h-8 items-center rounded-md border border-input bg-primary px-3 text-xs text-primary-foreground">
Живые Живые
</span> </span>
</SettingRow> </SettingRow>
@@ -1173,7 +1193,7 @@ export default function SettingsPage() {
// ── EvoBGP ── // ── EvoBGP ──
if (section === "EvoBGP") return ( if (section === "EvoBGP") return (
<div className="space-y-4"> <div className="flex flex-col gap-4">
{(mode !== "live" || backendStatus !== true) && ( {(mode !== "live" || backendStatus !== true) && (
<Frame dense className="w-full"> <Frame dense className="w-full">
<FramePanel className="px-4 py-4"> <FramePanel className="px-4 py-4">
@@ -1357,7 +1377,7 @@ export default function SettingsPage() {
// ── Уведомления ── // ── Уведомления ──
if (section === "Уведомления") return ( if (section === "Уведомления") return (
<div className="space-y-4"> <div className="flex flex-col gap-4">
<OpsPanel title="Каналы уведомлений" contentClassName="divide-y px-5"> <OpsPanel title="Каналы уведомлений" contentClassName="divide-y px-5">
<SettingRow label="Email" description="Отправка уведомлений на admin@routerlists.io"> <SettingRow label="Email" description="Отправка уведомлений на admin@routerlists.io">
<FormToggle checked={notifEmail} onChange={setNotifEmail} /> <FormToggle checked={notifEmail} onChange={setNotifEmail} />
@@ -1479,11 +1499,11 @@ export default function SettingsPage() {
// ── API-ключи ── // ── API-ключи ──
if (section === "API-ключи") return ( if (section === "API-ключи") return (
<div className="space-y-4"> <div className="flex flex-col gap-4">
<div className="flex justify-end"> <div className="flex justify-end">
<Button size="sm"><PlusIcon className="size-4" />Создать ключ</Button> <Button size="sm"><PlusIcon className="size-4" />Создать ключ</Button>
</div> </div>
<div className="space-y-3"> <div className="flex flex-col gap-3">
{apiKeys.map(k => ( {apiKeys.map(k => (
<Frame key={k.id} dense className="w-full"> <Frame key={k.id} dense className="w-full">
<FramePanel className="pt-4 pb-3 px-4"> <FramePanel className="pt-4 pb-3 px-4">
@@ -1546,7 +1566,7 @@ export default function SettingsPage() {
// ── Безопасность ── // ── Безопасность ──
if (section === "Безопасность") return ( if (section === "Безопасность") return (
<div className="space-y-4"> <div className="flex flex-col gap-4">
<OpsPanel title="Аутентификация" contentClassName="divide-y px-5"> <OpsPanel title="Аутентификация" contentClassName="divide-y px-5">
<SettingRow label="Двухфакторная аутентификация (MFA)" <SettingRow label="Двухфакторная аутентификация (MFA)"
description="TOTP / Authenticator app для всех администраторов"> description="TOTP / Authenticator app для всех администраторов">
@@ -1647,6 +1667,7 @@ export default function SettingsPage() {
{/* delete confirm */} {/* delete confirm */}
{dbRestoreFile && ( {dbRestoreFile && (
<DatabaseRestoreConfirm <DatabaseRestoreConfirm
open={!!dbRestoreFile}
filename={dbRestoreFile.name} filename={dbRestoreFile.name}
busy={dbRestoreBusy} busy={dbRestoreBusy}
onConfirm={() => { void handleSystemDatabaseRestoreConfirm() }} onConfirm={() => { void handleSystemDatabaseRestoreConfirm() }}
@@ -1658,6 +1679,7 @@ export default function SettingsPage() {
)} )}
{deleteTarget && ( {deleteTarget && (
<DeleteConfirm <DeleteConfirm
open={!!deleteTarget}
user={deleteTarget} user={deleteTarget}
onConfirm={() => { setUsers(p => p.filter(u => u.id !== deleteTarget.id)); setDeleteTarget(null) }} onConfirm={() => { setUsers(p => p.filter(u => u.id !== deleteTarget.id)); setDeleteTarget(null) }}
onCancel={() => setDeleteTarget(null)} onCancel={() => setDeleteTarget(null)}
+107 -107
View File
@@ -4,9 +4,22 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { servers as mockServers } from "@/lib/data" import { servers as mockServers } from "@/lib/data"
import { Flag } from "@/components/flag" import type { ServerStatus } from "@/lib/data"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
import { Badge } from "@/components/reui/badge"
import {
Frame,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { ScrollArea } from "@/components/ui/scroll-area"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import {
type ServerTileItem,
} from "@/components/server-tile-rail"
import { import {
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon, TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
} from "lucide-react" } from "lucide-react"
@@ -492,6 +505,64 @@ export default function TerminalPage() {
const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}` const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}`
const railItems = useMemo<ServerTileItem[]>(() => {
return termServers.map((s) => ({
id: s.uid,
name: s.name,
host: s.host,
country: s.country || undefined,
status: (s.status ?? undefined) as ServerStatus | undefined,
enabled: s.enabled,
selectable: s.enabled && s.status !== "offline",
title: [s.name, s.host].filter(Boolean).join(" · "),
}))
}, [termServers])
const handleSelectServer = useCallback((id: string) => {
setSelectedUid(id)
setRefreshKey((k) => k + 1)
}, [])
const railHeaderRight = isLive && !serversLoading
? <Badge variant="success-light" size="xs">LIVE</Badge>
: undefined
function QuickCmds() {
return (
<Frame dense spacing="sm" className="min-h-0 shrink-0">
<FramePanel className="flex max-h-56 flex-col gap-0 p-0">
<FrameHeader className="border-b px-3 py-2">
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Быстрые команды
</FrameTitle>
</FrameHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-0.5 p-1.5">
{QUICK_CMDS.map(({ cmd, label }) => (
<button
key={cmd}
type="button"
className="truncate rounded-md px-2 py-1.5 text-left font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => injectCommand(cmd)}
title={cmd}
>
{label}
</button>
))}
</div>
</ScrollArea>
<FrameFooter className="border-t text-[10px] text-muted-foreground/70">
<p>↑↓ — история команд</p>
<p>Ctrl+L — очистить экран</p>
{isLive
? <p className="text-info">Команды выполняются на роутере</p>
: <p>Режим: mock-данные</p>}
</FrameFooter>
</FramePanel>
</Frame>
)
}
function injectCommand(cmd: string) { function injectCommand(cmd: string) {
const el = document.querySelector<HTMLInputElement>(".terminal-input-active") const el = document.querySelector<HTMLInputElement>(".terminal-input-active")
if (!el) return if (!el) return
@@ -502,108 +573,39 @@ export default function TerminalPage() {
} }
return ( return (
<div className="flex flex-col h-full"> <ServerRailLayout
<PageHeader items={railItems}
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]} selectedId={selected?.uid ?? selectedUid}
actions={ onSelect={handleSelectServer}
<Button variant="outline" size="sm" onClick={() => { showAll={false}
setRefreshKey(k => k + 1) showCount={false}
if (isLive) setSelectedUid("") showType={false}
}}> headerRight={railHeaderRight}
<RefreshCwIcon className="size-4" />Переподключить loading={serversLoading}
</Button> extra={<QuickCmds />}
} contentClassName="overflow-hidden p-3 md:p-4"
/> header={
<PageHeader
<div className="flex-1 overflow-hidden p-6"> crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
<div className="grid grid-cols-[220px_1fr] gap-5 h-full"> actions={
<>
{/* ── sidebar ── */} <ServerRailMobileButton />
<div className="flex flex-col gap-4 overflow-y-auto min-h-0"> <Button
variant="outline"
{/* server picker */} size="sm"
<div> onClick={() => {
<div className="flex items-center gap-2 mb-2"> setRefreshKey((k) => k + 1)
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Узел</p> if (isLive) setSelectedUid("")
{isLive && serversLoading && ( }}
<Loader2Icon className="size-3 animate-spin text-muted-foreground" /> >
)} <RefreshCwIcon className="size-4" />
{isLive && !serversLoading && ( Переподключить
<span className="inline-flex items-center gap-1 text-[10px] font-medium rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5"> </Button>
<span className="size-1 rounded-full bg-emerald-400" />LIVE </>
</span> }
)} />
</div> }
>
{isLive && !serversLoading && liveServers.length === 0 && (
<p className="text-xs text-muted-foreground px-2.5">
Нет доступных серверов
</p>
)}
<div className="space-y-1">
{termServers.map(s => {
const isOffline = s.status === "offline"
const isSelected = s.uid === selectedUid
return (
<button
key={s.uid}
disabled={isOffline || !s.enabled}
onClick={() => { setSelectedUid(s.uid); setRefreshKey(k => k + 1) }}
className={cn(
"w-full text-left rounded-md px-2.5 py-2 text-xs transition-colors",
"flex items-center gap-2",
isSelected
? "bg-primary text-primary-foreground"
: "hover:bg-muted",
(isOffline || !s.enabled) && "opacity-40 cursor-not-allowed",
)}
>
<span className={cn(
"inline-block size-1.5 rounded-full shrink-0",
s.status === "online" ? "bg-emerald-500" :
s.status === "degraded" ? "bg-amber-400" :
s.status === null ? "bg-sky-400" : "bg-red-500",
)} />
{s.country && <Flag code={s.country} />}
<span className="truncate font-mono">{s.name}</span>
</button>
)
})}
</div>
</div>
{/* quick commands */}
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Быстрые команды
</p>
<div className="space-y-1">
{QUICK_CMDS.map(({ cmd, label }) => (
<button
key={cmd}
className="w-full text-left rounded-md px-2.5 py-1.5 text-[11px] font-mono text-muted-foreground hover:bg-muted hover:text-foreground transition-colors truncate block"
onClick={() => injectCommand(cmd)}
title={cmd}
>
{label}
</button>
))}
</div>
</div>
{/* hints */}
<div className="mt-auto text-[10px] text-muted-foreground/50 space-y-0.5 px-0.5">
<p>↑↓ — история команд</p>
<p>Ctrl+L — очистить экран</p>
{isLive
? <p className="text-sky-400/60">Команды выполняются на роутере</p>
: <p>Режим: mock-данные</p>
}
</div>
</div>
{/* ── terminal ── */}
{selected ? ( {selected ? (
<Terminal <Terminal
key={termKey} key={termKey}
@@ -612,12 +614,10 @@ export default function TerminalPage() {
backendUrl={backendUrl} backendUrl={backendUrl}
/> />
) : ( ) : (
<div className="flex items-center justify-center bg-[#0d1117] rounded-lg border border-[#30363d] text-[#8b949e] text-sm font-mono"> <div className="flex h-full items-center justify-center rounded-lg border border-[#30363d] bg-[#0d1117] font-mono text-sm text-[#8b949e]">
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"} {serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
</div> </div>
)} )}
</div> </ServerRailLayout>
</div>
</div>
) )
} }
+128 -134
View File
@@ -3,7 +3,8 @@
import { useState, useMemo, useEffect, useCallback } from "react" import { useState, useMemo, useEffect, useCallback } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Sparkline } from "@/components/sparkline" import { Sparkline } from "@/components/sparkline"
import { StatusDot } from "@/components/status-dot" import { StatusDot } from "@/components/status-dot"
@@ -13,7 +14,9 @@ import {
ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon, ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { fmtGB, fmtRate } from "@/lib/fmt-rate"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { useTrafficLive } from "@/hooks/use-traffic-live"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
// ─── helpers ────────────────────────────────────────────────────────────────── // ─── helpers ──────────────────────────────────────────────────────────────────
@@ -32,16 +35,6 @@ function addSeries(a: number[], b: number[]): number[] {
return a.map((v, i) => v + (b[i] ?? 0)) return a.map((v, i) => v + (b[i] ?? 0))
} }
function fmtMbps(v: number) {
if (v >= 1000) return `${(v / 1000).toFixed(2)} Гбит/с`
return `${v} Мбит/с`
}
function fmtGB(v: number) {
if (v >= 1000) return `${(v / 1000).toFixed(2)} ТБ`
return `${v.toFixed(1)} ГБ`
}
// ─── data model ─────────────────────────────────────────────────────────────── // ─── data model ───────────────────────────────────────────────────────────────
interface GreClientTraffic { interface GreClientTraffic {
@@ -318,63 +311,10 @@ function MiniAreaChart({ rx, tx, height = 44 }: { rx: number[]; tx: number[]; he
const line = (arr: number[]) => arr.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ") const line = (arr: number[]) => arr.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
return ( return (
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height, display: "block" }} preserveAspectRatio="none"> <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height, display: "block" }} preserveAspectRatio="none">
<path d={area(rx)} fill="hsl(var(--primary))" fillOpacity={0.12} /> <path d={area(rx)} fill="var(--chart-rx)" fillOpacity={0.12} />
<polyline points={line(rx)} fill="none" stroke="hsl(var(--primary))" strokeWidth="1.4" strokeLinejoin="round" /> <polyline points={line(rx)} fill="none" stroke="var(--chart-rx)" strokeWidth="1.4" strokeLinejoin="round" />
<path d={area(tx)} fill="#3b82f6" fillOpacity={0.10} /> <path d={area(tx)} fill="var(--chart-tx)" fillOpacity={0.10} />
<polyline points={line(tx)} fill="none" stroke="#3b82f6" strokeWidth="1.4" strokeLinejoin="round" /> <polyline points={line(tx)} fill="none" stroke="var(--chart-tx)" strokeWidth="1.4" strokeLinejoin="round" />
</svg>
)
}
function BigChart({ rx, tx }: { rx: number[]; tx: number[] }) {
const W = 900, H = 220
const pad = { l: 56, r: 16, t: 14, b: 32 }
const iw = W - pad.l - pad.r
const ih = H - pad.t - pad.b
const maxVal = Math.max(...rx, ...tx, 1) * 1.15
const xAt = (i: number, n: number) => pad.l + (i / (n - 1)) * iw
const yAt = (v: number) => pad.t + (1 - v / maxVal) * ih
const area = (arr: number[]) => {
const pts = arr.map((v, i) => `${xAt(i, arr.length).toFixed(1)},${yAt(v).toFixed(1)}`).join(" L ")
return `M ${pad.l},${pad.t + ih} L ${pts} L ${pad.l + iw},${pad.t + ih} Z`
}
const poly = (arr: number[]) => arr.map((v, i) => `${xAt(i, arr.length).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
const gridVals = [0, 0.25, 0.5, 0.75, 1]
return (
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: H, display: "block" }}>
<defs>
<linearGradient id="rx-big" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.22" />
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0" />
</linearGradient>
<linearGradient id="tx-big" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.18" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0" />
</linearGradient>
</defs>
{gridVals.map((p, i) => {
const y = pad.t + ih * (1 - p)
return (
<g key={i}>
<line x1={pad.l} x2={W - pad.r} y1={y} y2={y}
stroke="hsl(var(--border))" strokeDasharray={p === 0 ? "0" : "2 4"} />
<text x={pad.l - 8} y={y + 4} textAnchor="end" fontSize="10"
fill="hsl(var(--muted-foreground))" fontFamily="monospace">
{fmtMbps(Math.round(maxVal * p))}
</text>
</g>
)
})}
<path d={area(rx)} fill="url(#rx-big)" />
<polyline points={poly(rx)} fill="none" stroke="hsl(var(--primary))" strokeWidth="1.8" strokeLinejoin="round" />
<path d={area(tx)} fill="url(#tx-big)" />
<polyline points={poly(tx)} fill="none" stroke="#3b82f6" strokeWidth="1.8" strokeLinejoin="round" />
{[0, 12, 24, 36, 48, 59].map(i => (
<text key={i} x={xAt(i, 60)} y={H - 8} textAnchor="middle" fontSize="10"
fill="hsl(var(--muted-foreground))" fontFamily="monospace">
-{60 - i}м
</text>
))}
</svg> </svg>
) )
} }
@@ -401,11 +341,11 @@ function ServerCard({ s, selected, onClick }: { s: ServerTraffic; selected: bool
<div className="flex justify-between mt-2 gap-2"> <div className="flex justify-between mt-2 gap-2">
<div className="flex items-center gap-1 text-[11px]"> <div className="flex items-center gap-1 text-[11px]">
<ArrowDownIcon className="size-3 text-emerald-500" /> <ArrowDownIcon className="size-3 text-emerald-500" />
<span className="font-mono font-medium text-emerald-500">{fmtMbps(s.rxNow)}</span> <span className="font-mono font-medium text-emerald-500">{fmtRate(s.rxNow)}</span>
</div> </div>
<div className="flex items-center gap-1 text-[11px]"> <div className="flex items-center gap-1 text-[11px]">
<ArrowUpIcon className="size-3 text-blue-500" /> <ArrowUpIcon className="size-3 text-blue-500" />
<span className="font-mono font-medium text-blue-500">{fmtMbps(s.txNow)}</span> <span className="font-mono font-medium text-blue-500">{fmtRate(s.txNow)}</span>
</div> </div>
{s.greClients.length > 0 && ( {s.greClients.length > 0 && (
<div className="flex items-center gap-0.5 text-[10px] text-muted-foreground"> <div className="flex items-center gap-0.5 text-[10px] text-muted-foreground">
@@ -445,11 +385,11 @@ function UserCard({ u, selected, onClick }: { u: UserTraffic; selected: boolean;
<div className="flex justify-between mt-2 gap-2"> <div className="flex justify-between mt-2 gap-2">
<div className="flex items-center gap-1 text-[11px]"> <div className="flex items-center gap-1 text-[11px]">
<ArrowDownIcon className="size-3 text-emerald-500" /> <ArrowDownIcon className="size-3 text-emerald-500" />
<span className="font-mono font-medium text-emerald-500">{fmtMbps(u.rxNow)}</span> <span className="font-mono font-medium text-emerald-500">{fmtRate(u.rxNow)}</span>
</div> </div>
<div className="flex items-center gap-1 text-[11px]"> <div className="flex items-center gap-1 text-[11px]">
<ArrowUpIcon className="size-3 text-blue-500" /> <ArrowUpIcon className="size-3 text-blue-500" />
<span className="font-mono font-medium text-blue-500">{fmtMbps(u.txNow)}</span> <span className="font-mono font-medium text-blue-500">{fmtRate(u.txNow)}</span>
</div> </div>
</div> </div>
</> </>
@@ -483,11 +423,11 @@ function GreCard({ c, selected, onClick }: { c: GreClientTraffic; selected: bool
<div className="flex justify-between mt-2 gap-2"> <div className="flex justify-between mt-2 gap-2">
<div className="flex items-center gap-1 text-[11px]"> <div className="flex items-center gap-1 text-[11px]">
<ArrowDownIcon className="size-3 text-emerald-500" /> <ArrowDownIcon className="size-3 text-emerald-500" />
<span className="font-mono font-medium text-emerald-500">{fmtMbps(c.rxNow)}</span> <span className="font-mono font-medium text-emerald-500">{fmtRate(c.rxNow)}</span>
</div> </div>
<div className="flex items-center gap-1 text-[11px]"> <div className="flex items-center gap-1 text-[11px]">
<ArrowUpIcon className="size-3 text-blue-500" /> <ArrowUpIcon className="size-3 text-blue-500" />
<span className="font-mono font-medium text-blue-500">{fmtMbps(c.txNow)}</span> <span className="font-mono font-medium text-blue-500">{fmtRate(c.txNow)}</span>
</div> </div>
</div> </div>
</button> </button>
@@ -525,10 +465,10 @@ function GreClientRow({ c, showServer = false }: { c: GreClientTraffic; showServ
{/* live RX / TX */} {/* live RX / TX */}
<div className="shrink-0 text-right leading-tight"> <div className="shrink-0 text-right leading-tight">
<div className="flex items-center gap-1 justify-end text-xs font-mono font-medium text-emerald-600 dark:text-emerald-400"> <div className="flex items-center gap-1 justify-end text-xs font-mono font-medium text-emerald-600 dark:text-emerald-400">
<ArrowDownIcon className="size-3" />{fmtMbps(c.rxNow)} <ArrowDownIcon className="size-3" />{fmtRate(c.rxNow)}
</div> </div>
<div className="flex items-center gap-1 justify-end text-xs font-mono font-medium text-blue-600 dark:text-blue-400"> <div className="flex items-center gap-1 justify-end text-xs font-mono font-medium text-blue-600 dark:text-blue-400">
<ArrowUpIcon className="size-3" />{fmtMbps(c.txNow)} <ArrowUpIcon className="size-3" />{fmtRate(c.txNow)}
</div> </div>
</div> </div>
@@ -581,19 +521,6 @@ function DetailHeader({ range, setRange, children }: {
) )
} }
function ChartLegend() {
return (
<div className="flex gap-5 mb-2">
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-block size-2 rounded-full bg-primary/70" />RX (входящий)
</span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-block size-2 rounded-full bg-blue-500/70" />TX (исходящий)
</span>
</div>
)
}
function OfflinePlaceholder({ text = "Нет данных — объект недоступен" }: { text?: string }) { function OfflinePlaceholder({ text = "Нет данных — объект недоступен" }: { text?: string }) {
return ( return (
<div className="flex items-center justify-center h-[220px] text-muted-foreground/40"> <div className="flex items-center justify-center h-[220px] text-muted-foreground/40">
@@ -611,18 +538,18 @@ function TotalsRow({ rxTotal, txTotal, rxSeries, txSeries }: {
return ( return (
<div className="mt-4 pt-4 border-t grid grid-cols-2 gap-4"> <div className="mt-4 pt-4 border-t grid grid-cols-2 gap-4">
<div> <div>
<p className="text-xs text-muted-foreground mb-1">Получено сегодня</p> <p className="text-xs text-muted-foreground mb-1">Получено за период</p>
<p className="text-lg font-semibold tabular-nums"> <p className="text-lg font-semibold tabular-nums">
{rxTotal.toFixed(1)} <span className="text-sm font-normal text-muted-foreground">ГБ</span> {rxTotal.toFixed(1)} <span className="text-sm font-normal text-muted-foreground">ГБ</span>
</p> </p>
<Sparkline data={rxSeries} width={180} height={28} color="hsl(var(--primary))" filled /> <Sparkline data={rxSeries} width={180} height={28} color="var(--chart-rx)" filled />
</div> </div>
<div> <div>
<p className="text-xs text-muted-foreground mb-1">Отправлено сегодня</p> <p className="text-xs text-muted-foreground mb-1">Отправлено за период</p>
<p className="text-lg font-semibold tabular-nums"> <p className="text-lg font-semibold tabular-nums">
{txTotal.toFixed(1)} <span className="text-sm font-normal text-muted-foreground">ГБ</span> {txTotal.toFixed(1)} <span className="text-sm font-normal text-muted-foreground">ГБ</span>
</p> </p>
<Sparkline data={txSeries} width={180} height={28} color="#3b82f6" filled /> <Sparkline data={txSeries} width={180} height={28} color="var(--chart-tx)" filled />
</div> </div>
</div> </div>
) )
@@ -630,7 +557,18 @@ function TotalsRow({ rxTotal, txTotal, rxSeries, txSeries }: {
// ─── detail panels ──────────────────────────────────────────────────────────── // ─── detail panels ────────────────────────────────────────────────────────────
function ServerDetail({ sel, range, setRange }: { sel: ServerTraffic; range: Range; setRange: (r: Range) => void }) { function ServerDetail({
sel, range, setRange, liveRx, liveTx, liveHint,
}: {
sel: ServerTraffic
range: Range
setRange: (r: Range) => void
liveRx?: number
liveTx?: number
liveHint?: string
}) {
const rxNow = liveRx ?? sel.rxNow
const txNow = liveTx ?? sel.txNow
return ( return (
<> <>
<DetailHeader range={range} setRange={setRange}> <DetailHeader range={range} setRange={setRange}>
@@ -640,13 +578,43 @@ function ServerDetail({ sel, range, setRange }: { sel: ServerTraffic; range: Ran
<Flag code={sel.country} />{sel.site} <Flag code={sel.country} />{sel.site}
</span> </span>
</DetailHeader> </DetailHeader>
<ChartLegend /> {sel.status === "offline" ? <OfflinePlaceholder /> : <TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />}
{sel.status === "offline" ? <OfflinePlaceholder /> : <BigChart rx={sel.rxSeries} tx={sel.txSeries} />} <div className="mt-4 pt-4 border-t">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t"> <KpiStatGrid
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtMbps(sel.rxNow)} /> aria-label="Скорость выбранного сервера"
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtMbps(sel.txNow)} /> items={[
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtMbps(sel.rxPeak)} /> {
<StatChip icon={<TrendingDownIcon className="size-3.5 text-purple-500" />} label="Сессий" value={`${sel.sessions}`} /> id: "rx-now",
label: "RX сейчас",
value: fmtRate(rxNow),
hint: liveHint,
icon: <ArrowDownIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "tx-now",
label: "TX сейчас",
value: fmtRate(txNow),
hint: liveHint,
icon: <ArrowUpIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "rx-peak",
label: "Пик RX",
value: fmtRate(sel.rxPeak),
icon: <TrendingUpIcon className="size-4" />,
iconClassName: "text-warning",
},
{
id: "tx-peak",
label: "Пик TX",
value: fmtRate(sel.txPeak),
icon: <TrendingDownIcon className="size-4" />,
iconClassName: "text-warning",
},
]}
/>
</div> </div>
<TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} /> <TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} />
{sel.greClients.length > 0 && ( {sel.greClients.length > 0 && (
@@ -680,12 +648,11 @@ function UserDetail({ sel, range, setRange }: { sel: UserTraffic; range: Range;
<OfflinePlaceholder text="Нет GRE-клиентов у этого пользователя" /> <OfflinePlaceholder text="Нет GRE-клиентов у этого пользователя" />
) : ( ) : (
<> <>
<ChartLegend /> <TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />
<BigChart rx={sel.rxSeries} tx={sel.txSeries} />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t">
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtMbps(sel.rxNow)} /> <StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtRate(sel.rxNow)} />
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtMbps(sel.txNow)} /> <StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtRate(sel.txNow)} />
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtMbps(sel.rxPeak)} /> <StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtRate(sel.rxPeak)} />
<StatChip icon={<CableIcon className="size-3.5 text-purple-500" />} label="GRE-клиентов" value={`${sel.greClients.length}`} /> <StatChip icon={<CableIcon className="size-3.5 text-purple-500" />} label="GRE-клиентов" value={`${sel.greClients.length}`} />
</div> </div>
<TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} /> <TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} />
@@ -716,13 +683,12 @@ function GreDetail({ sel, range, setRange }: { sel: GreClientTraffic; range: Ran
<Flag code={sel.serverCountry} />{sel.serverSite} <Flag code={sel.serverCountry} />{sel.serverSite}
</span> </span>
</DetailHeader> </DetailHeader>
<ChartLegend /> {sel.status === "offline" ? <OfflinePlaceholder /> : <TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />}
{sel.status === "offline" ? <OfflinePlaceholder /> : <BigChart rx={sel.rxSeries} tx={sel.txSeries} />}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t">
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtMbps(sel.rxNow)} /> <StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtRate(sel.rxNow)} />
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtMbps(sel.txNow)} /> <StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtRate(sel.txNow)} />
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtMbps(sel.rxPeak)} /> <StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtRate(sel.rxPeak)} />
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик TX" value={fmtMbps(sel.txPeak)} /> <StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик TX" value={fmtRate(sel.txPeak)} />
</div> </div>
<TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} /> <TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} />
<div className="mt-4 pt-4 border-t grid grid-cols-2 sm:grid-cols-3 gap-4"> <div className="mt-4 pt-4 border-t grid grid-cols-2 sm:grid-cols-3 gap-4">
@@ -778,6 +744,12 @@ export default function TrafficPage() {
const [detailBusy, setDetailBusy] = useState(false) const [detailBusy, setDetailBusy] = useState(false)
const [liveDetailServer, setLiveDetailServer] = useState<ServerTraffic | null>(null) const [liveDetailServer, setLiveDetailServer] = useState<ServerTraffic | null>(null)
const effectiveMode: GroupMode = isLive ? "servers" : groupMode const effectiveMode: GroupMode = isLive ? "servers" : groupMode
const { sample: liveSample, error: liveStreamError } = useTrafficLive({
enabled: isLive && effectiveMode === "servers" && liveServers.some((s) => s.id === selectedId),
backendUrl,
serverId: selectedId,
iface: selectedIface,
})
const toLiveServer = (s: LiveTrafficServer): ServerTraffic => { const toLiveServer = (s: LiveTrafficServer): ServerTraffic => {
return { return {
@@ -917,6 +889,7 @@ export default function TrafficPage() {
}, [sortField, sortDir, q]) }, [sortField, sortDir, q])
const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic]) const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic])
const detailServer = liveDetailServer?.id === selectedId ? liveDetailServer : selServer
const selUser = useMemo(() => userTraffic.find(u => u.id === selectedId) ?? userTraffic[0], [selectedId]) const selUser = useMemo(() => userTraffic.find(u => u.id === selectedId) ?? userTraffic[0], [selectedId])
const selGre = useMemo(() => greClients.find(c => c.id === selectedId) ?? greClients[0], [selectedId]) const selGre = useMemo(() => greClients.find(c => c.id === selectedId) ?? greClients[0], [selectedId])
@@ -965,27 +938,39 @@ export default function TrafficPage() {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{/* ── summary stat cards ── */} <KpiStatGrid
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> aria-label="Сводка трафика"
{[ items={[
{ icon: <ArrowDownIcon className="size-3.5 text-emerald-500" />, label: "RX сейчас", value: fmtMbps(totalRx) }, {
{ icon: <ArrowUpIcon className="size-3.5 text-blue-500" />, label: "TX сейчас", value: fmtMbps(totalTx) }, id: "rx",
{ icon: <TrendingUpIcon className="size-3.5 text-amber-500" />, label: "Пик RX", value: fmtMbps(peakRx) }, label: "RX сейчас",
{ icon: <TrendingUpIcon className="size-3.5 text-amber-500" />, label: "Пик TX", value: fmtMbps(peakTx) }, value: fmtRate(totalRx),
].map(({ icon, label, value }) => ( icon: <ArrowDownIcon className="size-4" />,
<Frame key={label} className="h-full overflow-hidden"> iconClassName: "text-success",
<FramePanel className="relative isolate flex h-full items-start gap-3"> },
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground"> {
{icon} id: "tx",
</IconTile> label: "TX сейчас",
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> value: fmtRate(totalTx),
<p className="text-muted-foreground text-sm font-medium">{label}</p> icon: <ArrowUpIcon className="size-4" />,
<p className="text-2xl leading-none font-bold tabular-nums tracking-tight">{value}</p> iconClassName: "text-info",
</div> },
</FramePanel> {
</Frame> id: "peak-rx",
))} label: "Пик RX",
</div> value: fmtRate(peakRx),
icon: <TrendingUpIcon className="size-4" />,
iconClassName: "text-warning",
},
{
id: "peak-tx",
label: "Пик TX",
value: fmtRate(peakTx),
icon: <TrendingUpIcon className="size-4" />,
iconClassName: "text-warning",
},
]}
/>
<div className="grid grid-cols-[300px_1fr] gap-5 items-start"> <div className="grid grid-cols-[300px_1fr] gap-5 items-start">
@@ -1091,7 +1076,16 @@ export default function TrafficPage() {
</div> </div>
</div> </div>
)} )}
{effectiveMode === "servers" && (liveDetailServer ?? selServer) && <ServerDetail sel={(liveDetailServer ?? selServer)!} range={range} setRange={setRange} />} {effectiveMode === "servers" && detailServer && (
<ServerDetail
sel={detailServer}
range={range}
setRange={setRange}
liveRx={liveSample?.rxMbps}
liveTx={liveSample?.txMbps}
liveHint={liveSample ? "live" : (liveStreamError ? "история" : undefined)}
/>
)}
{effectiveMode === "users" && <UserDetail sel={selUser} range={range} setRange={setRange} />} {effectiveMode === "users" && <UserDetail sel={selUser} range={range} setRange={setRange} />}
{effectiveMode === "gre" && <GreDetail sel={selGre} range={range} setRange={setRange} />} {effectiveMode === "gre" && <GreDetail sel={selGre} range={range} setRange={setRange} />}
</FramePanel> </FramePanel>
+89 -46
View File
@@ -4,7 +4,7 @@ import { useState, useMemo, useEffect, useRef, useCallback } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit" import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
@@ -961,29 +961,58 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
</Alert> </Alert>
)} )}
{/* ── KPI summary ───────────────────────────────────────────────────── */} <KpiStatGrid
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3"> aria-label="Сводка ресурсов"
{[ items={[
{ icon: <ServerIcon className="size-4 text-muted-foreground" />, label: String(rows.length), sub: "серверов всего", color: "text-foreground" }, {
{ icon: <CpuIcon className="size-4" />, label: `${avgCpu}%`, sub: "средний CPU", color: resPctColor(avgCpu) }, id: "servers",
{ icon: <HardDriveIcon className="size-4" />, label: `${avgRam}%`, sub: "средний RAM", color: resPctColor(avgRam) }, label: "Серверов всего",
{ icon: <AlertCircleIcon className="size-4" />, label: String(highCpu), sub: "CPU > 85%", color: highCpu > 0 ? "text-red-500" : "text-muted-foreground" }, value: rows.length,
{ icon: <AlertCircleIcon className="size-4" />, label: String(highRam), sub: "RAM > 85%", color: highRam > 0 ? "text-red-500" : "text-muted-foreground" }, icon: <ServerIcon className="size-4" />,
{ icon: <AlertCircleIcon className="size-4" />, label: String(highHdd), sub: "Диск > 85%", color: highHdd > 0 ? "text-amber-500" : "text-muted-foreground" }, iconClassName: "text-muted-foreground",
].map(kpi => ( },
<Frame key={kpi.sub} className="h-full"> {
<FramePanel className="relative isolate flex h-full items-start gap-3"> id: "cpu",
<IconTile variant="elevated" aria-hidden="true" className={cn("size-10.5", kpi.color)}> label: "Средний CPU",
{kpi.icon} value: `${avgCpu}%`,
</IconTile> icon: <CpuIcon className="size-4" />,
<div className="min-w-0 flex flex-col gap-0.5"> iconClassName: avgCpu >= 85 ? "text-destructive" : avgCpu >= 70 ? "text-warning" : "text-success",
<p className={cn("text-xl leading-none font-bold tabular-nums", kpi.color)}>{kpi.label}</p> variant: avgCpu >= 85 ? "destructive" : avgCpu >= 70 ? "warning" : "default",
<p className="text-[11px] text-muted-foreground">{kpi.sub}</p> },
</div> {
</FramePanel> id: "ram",
</Frame> label: "Средний RAM",
))} value: `${avgRam}%`,
</div> icon: <HardDriveIcon className="size-4" />,
iconClassName: avgRam >= 85 ? "text-destructive" : avgRam >= 70 ? "text-warning" : "text-success",
variant: avgRam >= 85 ? "destructive" : avgRam >= 70 ? "warning" : "default",
},
{
id: "high-cpu",
label: "CPU > 85%",
value: highCpu,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: highCpu > 0 ? "text-destructive" : "text-muted-foreground",
variant: highCpu > 0 ? "destructive" : "default",
},
{
id: "high-ram",
label: "RAM > 85%",
value: highRam,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: highRam > 0 ? "text-destructive" : "text-muted-foreground",
variant: highRam > 0 ? "destructive" : "default",
},
{
id: "high-hdd",
label: "Диск > 85%",
value: highHdd,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: highHdd > 0 ? "text-warning" : "text-muted-foreground",
variant: highHdd > 0 ? "warning" : "default",
},
]}
/>
{/* ── Table ─────────────────────────────────────────────────────────── */} {/* ── Table ─────────────────────────────────────────────────────────── */}
<DataPageCard> <DataPageCard>
@@ -2022,28 +2051,42 @@ export default function UptimePage() {
const maxTx = doneRuns.length ? Math.max(...doneRuns.map(r => r.txAvgMbps)) : null const maxTx = doneRuns.length ? Math.max(...doneRuns.map(r => r.txAvgMbps)) : null
const maxRx = doneRuns.length ? Math.max(...doneRuns.map(r => r.rxAvgMbps)) : null const maxRx = doneRuns.length ? Math.max(...doneRuns.map(r => r.rxAvgMbps)) : null
return ( return (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 px-6 py-4 border-b bg-muted/10 shrink-0"> <div className="px-6 py-4 border-b bg-muted/10 shrink-0">
{[ <KpiStatGrid
{ label: "Speed-пробы", value: speedProbes.length, unit: "шт", color: "" }, aria-label="Сводка speed-проб"
{ label: "Тестов выполнено", value: doneRuns.length, unit: "run", color: "" }, items={[
{ label: "Макс TX", value: maxTx != null ? `${maxTx}` : "—", unit: maxTx != null ? "Мбит/с" : "", color: "text-[var(--chart-tx)]" }, {
{ label: "Макс RX", value: maxRx != null ? `${maxRx}` : "—", unit: maxRx != null ? "Мбит/с" : "", color: "text-[var(--chart-rx)]" }, id: "probes",
].map(k => ( label: "Speed-пробы",
<Frame key={k.label} className="h-full"> value: `${speedProbes.length} шт`,
<FramePanel className="flex flex-col gap-0.5"> icon: <ArrowUpDownIcon className="size-4" />,
<p className="text-muted-foreground text-sm font-medium">{k.label}</p> iconClassName: "text-muted-foreground",
<div className="flex items-baseline gap-1"> },
<span className={cn("text-2xl leading-none font-bold tabular-nums", k.color)}>{k.value}</span> {
{k.unit && <span className="text-xs text-muted-foreground">{k.unit}</span>} id: "runs",
</div> label: "Тестов выполнено",
{runningCnt > 0 && k.label === "Тестов выполнено" && ( value: `${doneRuns.length} run`,
<p className="text-[11px] text-[var(--status-degraded-fg)] flex items-center gap-1 mt-0.5"> hint: runningCnt > 0 ? `${runningCnt} выполняется` : undefined,
<RefreshCwIcon className="size-2.5 animate-spin" />{runningCnt} выполняется icon: <PlayIcon className="size-4" />,
</p> iconClassName: runningCnt > 0 ? "text-warning" : "text-muted-foreground",
)} variant: runningCnt > 0 ? "warning" : "default",
</FramePanel> },
</Frame> {
))} id: "max-tx",
label: "Макс TX",
value: maxTx != null ? `${maxTx} Мбит/с` : "—",
icon: <ArrowUpIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "max-rx",
label: "Макс RX",
value: maxRx != null ? `${maxRx} Мбит/с` : "—",
icon: <ArrowDownIcon className="size-4" />,
iconClassName: "text-success",
},
]}
/>
</div> </div>
) )
})()} })()}
+50 -78
View File
@@ -4,22 +4,16 @@ import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { vxlanTunnels, servers } from "@/lib/data" import { vxlanTunnels, servers } from "@/lib/data"
import type { VxlanTunnel } from "@/lib/data" import type { VxlanTunnel } from "@/lib/data"
import { Frame, FramePanel } from "@/components/reui/frame" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar" import { DataPageToolbar } from "@/components/data-page-toolbar"
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid" import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { import {
NetworkIcon, PlusIcon, CopyIcon, CheckIcon, NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon,
CodeXmlIcon, LayersIcon,
} from "lucide-react" } from "lucide-react"
import { import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
// ─── helpers ────────────────────────────────────────────────────────────────── // ─── helpers ──────────────────────────────────────────────────────────────────
@@ -70,57 +64,23 @@ function generateVxlanRsc(t: VxlanTunnel): string {
function ExportSheet({ open, tunnel, onClose }: { function ExportSheet({ open, tunnel, onClose }: {
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void open: boolean; tunnel: VxlanTunnel | null; onClose: () => void
}) { }) {
const [copied, setCopied] = useState(false)
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel]) const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel])
function handleCopy() {
navigator.clipboard.writeText(code).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 2000)
})
}
return ( return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}> <CodeExportSheet
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl"> open={open}
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b"> onClose={onClose}
<div className="flex items-start justify-between gap-4"> title="Экспорт VXLAN"
<div> description="RouterOS 7.x · /interface/vxlan + vteps"
<SheetTitle>Экспорт VXLAN</SheetTitle> formats={[
<SheetDescription>RouterOS 7.x · /interface/vxlan + vteps</SheetDescription> {
</div> id: "rsc",
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}> label: "MikroTik .rsc",
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>} filename: `${tunnel?.name ?? "vxlan"}.rsc`,
</Button> code,
</div> },
</SheetHeader> ]}
<div className="flex-1 overflow-y-auto"> />
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isCmd = /^\//.test(line.trimStart())
const isParam = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isComment ? "text-muted-foreground"
: isCmd ? "text-sky-400"
: isParam ? "text-violet-300"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1" onClick={handleCopy}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
) )
} }
@@ -157,27 +117,39 @@ export default function VxlanPage() {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{/* KPI */} <KpiStatGrid
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> aria-label="Сводка VXLAN"
{[ items={[
{ label: "Туннелей", value: vxlanTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> }, {
{ label: "Активных", value: upCount, icon: <LayersIcon className="size-4 text-emerald-500" /> }, id: "tunnels",
{ label: "Уникальных VNI", value: vnis, icon: <LayersIcon className="size-4 text-sky-400" /> }, label: "Туннелей",
{ label: "Серверов", value: new Set(vxlanTunnels.map((t) => t.serverId)).size, icon: <NetworkIcon className="size-4 text-violet-400" /> }, value: vxlanTunnels.length,
].map((s) => ( icon: <NetworkIcon className="size-4" />,
<Frame key={s.label} className="h-full"> iconClassName: "text-muted-foreground",
<FramePanel className="relative isolate flex h-full items-start gap-3"> },
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground"> {
{s.icon} id: "up",
</IconTile> label: "Активных",
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> value: upCount,
<p className="text-muted-foreground text-sm font-medium">{s.label}</p> icon: <LayersIcon className="size-4" />,
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p> iconClassName: "text-success",
</div> },
</FramePanel> {
</Frame> id: "vni",
))} label: "Уникальных VNI",
</div> value: vnis,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "servers",
label: "Серверов",
value: new Set(vxlanTunnels.map((t) => t.serverId)).size,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{/* Info banner */} {/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm"> <div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
+430 -168
View File
@@ -10,10 +10,30 @@ import {
WireguardDataGrid, WireguardDataGrid,
type WgIfaceWithServer, type WgIfaceWithServer,
} from "@/components/data-grids/wireguard-data-grid" } from "@/components/data-grids/wireguard-data-grid"
import {
WireguardPeersGrid,
type WgPeerRow,
} from "@/components/data-grids/wireguard-peers-grid"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Frame, FramePanel } from "@/components/reui/frame" import {
import { IconTile } from "@/components/reui/icon-tile" Alert,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
import { import {
@@ -31,12 +51,25 @@ import { WgCreateSheet, type WgCreateFormState } from "@/components/wireguard/wg
import { WgImportSheet } from "@/components/wireguard/wg-import-sheet" import { WgImportSheet } from "@/components/wireguard/wg-import-sheet"
import { WgExportSheet } from "@/components/wireguard/wg-export-sheet" import { WgExportSheet } from "@/components/wireguard/wg-export-sheet"
import { WgPeerSheet, type WgPeerFormState } from "@/components/wireguard/wg-peer-sheet" import { WgPeerSheet, type WgPeerFormState } from "@/components/wireguard/wg-peer-sheet"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import {
ALL_SERVERS_ID,
type ServerTileItem,
} from "@/components/server-tile-rail"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
ShieldCheckIcon, PlusIcon, KeyRoundIcon, ShieldCheckIcon, PlusIcon, KeyRoundIcon,
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
Trash2Icon, CodeXmlIcon, AlertCircleIcon,
} from "lucide-react" } from "lucide-react"
type WgWorkspaceTab = "interfaces" | "peers" | "cli"
type WgStatusFilter = "all" | "up" | "down"
type WgFailure = { serverId: string; serverName?: string; error: string }
type PendingDelete =
| { kind: "iface"; iface: WgIfaceWithServer }
| { kind: "peer"; iface: WgIfaceWithServer; peerId: string }
function collectMockInterfaces(): WgIfaceWithServer[] { function collectMockInterfaces(): WgIfaceWithServer[] {
const result: WgIfaceWithServer[] = [] const result: WgIfaceWithServer[] = []
for (const srv of mockServers) { for (const srv of mockServers) {
@@ -94,7 +127,11 @@ interface BackendServer {
name: string name: string
host: string host: string
country: string country: string
type?: Server["type"]
enabled: boolean enabled: boolean
status?: Server["status"]
latency?: number | null
asn?: string
} }
function mapBackendServer(s: BackendServer): Server { function mapBackendServer(s: BackendServer): Server {
@@ -106,11 +143,11 @@ function mapBackendServer(s: BackendServer): Server {
os: "—", os: "—",
site: "", site: "",
country: s.country || "UN", country: s.country || "UN",
asn: "", asn: s.asn ?? "",
type: "exit-node", type: s.type ?? "exit-node",
enabled: s.enabled, enabled: s.enabled,
status: "online", status: s.status ?? "online",
latency: null, latency: s.latency ?? null,
sessions: 0, sessions: 0,
} }
} }
@@ -126,20 +163,31 @@ function parseEndpoint(endpoint: string): { address?: string; port?: number } {
} }
} }
function peerRowId(iface: WgIfaceWithServer, peer: WgIfaceWithServer["peers"][number], index: number): string {
return peer.id ?? peer.rosId ?? `${iface.id}-${peer.publicKey}-${index}`
}
export default function WireGuardPage() { export default function WireGuardPage() {
const { mode, backendUrl } = useDataSource() const { mode, backendUrl } = useDataSource()
const isLive = mode === "live" const isLive = mode === "live"
const [liveIfaces, setLiveIfaces] = useState<WgIfaceWithServer[]>([]) const [liveIfaces, setLiveIfaces] = useState<WgIfaceWithServer[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([]) const [liveServers, setLiveServers] = useState<Server[]>([])
const [failures, setFailures] = useState<WgFailure[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [workspaceTab, setWorkspaceTab] = useState<WgWorkspaceTab>("interfaces")
const [statusFilter, setStatusFilter] = useState<WgStatusFilter>("all")
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [importOpen, setImportOpen] = useState(false) const [importOpen, setImportOpen] = useState(false)
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null) const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
const [exportInitialTab, setExportInitialTab] = useState<"rsc" | "conf" | "peer">("rsc")
const [exportPeerId, setExportPeerId] = useState<string | null>(null)
const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null) const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null)
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
const [liveExport, setLiveExport] = useState<{ const [liveExport, setLiveExport] = useState<{
rsc?: string rsc?: string
conf?: string conf?: string
@@ -157,6 +205,7 @@ export default function WireGuardPage() {
]) ])
setLiveIfaces(wg.interfaces.map(dtoToRow)) setLiveIfaces(wg.interfaces.map(dtoToRow))
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer)) setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
setFailures(wg.failures ?? [])
if (wg.failures?.length) { if (wg.failures?.length) {
toast.warning( toast.warning(
`Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`, `Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
@@ -165,6 +214,7 @@ export default function WireGuardPage() {
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка загрузки WireGuard") toast.error(e instanceof Error ? e.message : "Ошибка загрузки WireGuard")
setLiveIfaces([]) setLiveIfaces([])
setFailures([])
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -175,6 +225,7 @@ export default function WireGuardPage() {
queueMicrotask(() => { queueMicrotask(() => {
setLiveIfaces([]) setLiveIfaces([])
setLiveServers([]) setLiveServers([])
setFailures([])
}) })
return return
} }
@@ -186,10 +237,35 @@ export default function WireGuardPage() {
const displayIfaces = isLive ? liveIfaces : collectMockInterfaces() const displayIfaces = isLive ? liveIfaces : collectMockInterfaces()
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled) const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const filtered = useMemo(() => { const effectiveServerId =
if (!search) return displayIfaces selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
? selectedServerId
: ALL_SERVERS_ID
const scopedIfaces = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return displayIfaces
return displayIfaces.filter((i) => i.serverId === effectiveServerId)
}, [displayIfaces, effectiveServerId])
const statusCounts = useMemo(() => {
let up = 0
let down = 0
for (const iface of scopedIfaces) {
if (iface.status === "up") up += 1
else down += 1
}
return { all: scopedIfaces.length, up, down }
}, [scopedIfaces])
const statusFiltered = useMemo(() => {
if (statusFilter === "all") return scopedIfaces
return scopedIfaces.filter((i) => i.status === statusFilter)
}, [scopedIfaces, statusFilter])
const filteredIfaces = useMemo(() => {
if (!search) return statusFiltered
const q = search.toLowerCase() const q = search.toLowerCase()
return displayIfaces.filter( return statusFiltered.filter(
(i) => (i) =>
i.name.toLowerCase().includes(q) || i.name.toLowerCase().includes(q) ||
i.serverName.toLowerCase().includes(q) || i.serverName.toLowerCase().includes(q) ||
@@ -199,14 +275,47 @@ export default function WireGuardPage() {
(p.endpoint ?? "").includes(q), (p.endpoint ?? "").includes(q),
), ),
) )
}, [displayIfaces, search]) }, [statusFiltered, search])
const totalPeers = displayIfaces.reduce((s, i) => s + i.peers.length, 0) const peerRows = useMemo<WgPeerRow[]>(() => {
const onlinePeers = displayIfaces.reduce( const q = search.toLowerCase()
const rows: WgPeerRow[] = []
for (const iface of scopedIfaces) {
iface.peers.forEach((peer, index) => {
const id = peerRowId(iface, peer, index)
if (q) {
const hay = [
peer.publicKey,
peer.name ?? "",
peer.endpoint ?? "",
peer.allowedIps.join(" "),
iface.name,
iface.serverName,
].join(" ").toLowerCase()
if (!hay.includes(q)) return
}
rows.push({
...peer,
id,
ifaceId: iface.id,
ifaceName: iface.name,
serverId: iface.serverId,
serverName: iface.serverName,
serverCountry: iface.serverCountry,
})
})
}
return rows
}, [scopedIfaces, search])
const totalPeers = scopedIfaces.reduce((s, i) => s + i.peers.length, 0)
const onlinePeers = scopedIfaces.reduce(
(s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length, (s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length,
0, 0,
) )
const upIfaces = displayIfaces.filter((i) => i.status === "up").length const upIfaces = scopedIfaces.filter((i) => i.status === "up").length
const compactServer = effectiveServerId !== ALL_SERVERS_ID
const sheetServerId = compactServer ? effectiveServerId : undefined
const serverOptions = displayServers.map((s) => ({ const serverOptions = displayServers.map((s) => ({
id: s.id, id: s.id,
@@ -214,6 +323,25 @@ export default function WireGuardPage() {
host: s.host, host: s.host,
})) }))
const railItems = useMemo<ServerTileItem[]>(() => {
const counts = new Map<string, number>()
for (const iface of displayIfaces) {
counts.set(iface.serverId, (counts.get(iface.serverId) ?? 0) + 1)
}
return displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
count: counts.get(s.id) ?? 0,
enabled: s.enabled,
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
}))
}, [displayServers, displayIfaces])
async function handleCreate(form: WgCreateFormState) { async function handleCreate(form: WgCreateFormState) {
if (!isLive) { if (!isLive) {
toast.info("Создание на роутер доступно только в live-режиме") toast.info("Создание на роутер доступно только в live-режиме")
@@ -302,15 +430,26 @@ export default function WireGuardPage() {
} }
} }
async function handleDelete(iface: WgIfaceWithServer) { async function confirmDelete() {
if (!isLive || !iface.rosId) { if (!pendingDelete) return
if (!isLive) {
toast.info("Доступно только в live-режиме") toast.info("Доступно только в live-режиме")
setPendingDelete(null)
return return
} }
if (!window.confirm(`Удалить интерфейс ${iface.name} на ${iface.serverName}?`)) return
try { try {
await deleteWireGuardInterface(backendUrl, iface.serverId, iface.rosId) if (pendingDelete.kind === "iface") {
toast.success("Удалено") if (!pendingDelete.iface.rosId) {
toast.info("Доступно только в live-режиме")
return
}
await deleteWireGuardInterface(backendUrl, pendingDelete.iface.serverId, pendingDelete.iface.rosId)
toast.success("Удалено")
} else {
await deleteWireGuardPeer(backendUrl, pendingDelete.iface.serverId, pendingDelete.peerId)
toast.success("Пир удалён")
}
setPendingDelete(null)
await loadLive() await loadLive()
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка удаления") toast.error(e instanceof Error ? e.message : "Ошибка удаления")
@@ -348,21 +487,6 @@ export default function WireGuardPage() {
} }
} }
async function handleDeletePeer(iface: WgIfaceWithServer, peerId: string) {
if (!isLive) {
toast.info("Доступно только в live-режиме")
return
}
if (!window.confirm("Удалить пира?")) return
try {
await deleteWireGuardPeer(backendUrl, iface.serverId, peerId)
toast.success("Пир удалён")
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка")
}
}
async function handleLiveExport(format: "rsc" | "conf" | "peer-conf") { async function handleLiveExport(format: "rsc" | "conf" | "peer-conf") {
if (!exportIface || !isLive) return if (!exportIface || !isLive) return
setExportBusy(true) setExportBusy(true)
@@ -372,6 +496,7 @@ export default function WireGuardPage() {
interfaceName: exportIface.name, interfaceName: exportIface.name,
format, format,
includePrivateKey: format !== "peer-conf", includePrivateKey: format !== "peer-conf",
peerId: format === "peer-conf" ? (exportPeerId ?? undefined) : undefined,
}) })
setLiveExport((prev) => ({ setLiveExport((prev) => ({
...prev, ...prev,
@@ -389,149 +514,255 @@ export default function WireGuardPage() {
} }
} }
function openExport(iface: WgIfaceWithServer, tab: "rsc" | "conf" | "peer" = "rsc", peerId?: string) {
setLiveExport(null)
setExportInitialTab(tab)
setExportPeerId(peerId ?? null)
setExportIface(iface)
}
const emptyCreateAction = (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<PlusIcon className="size-4" />
Новый интерфейс
</Button>
)
return ( return (
<div className="flex flex-col h-full"> <>
<PageHeader <ServerRailLayout
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]} items={railItems}
actions={ selectedId={effectiveServerId}
<> onSelect={setSelectedServerId}
{isLive && ( showAll
<Button allCount={displayIfaces.length}
size="sm" loading={isLive && loading && displayServers.length === 0}
variant="outline" header={
disabled={loading} <PageHeader
onClick={() => void loadLive()} crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
> actions={
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} /> <>
Обновить <ServerRailMobileButton />
{isLive && (
<Button
size="sm"
variant="outline"
disabled={loading}
onClick={() => void loadLive()}
>
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
Обновить
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />
Импорт
</Button> </Button>
)} <Button size="sm" onClick={() => setCreateOpen(true)}>
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}> <PlusIcon className="size-4" />
<UploadIcon className="size-4" /> Новый интерфейс
Импорт </Button>
</Button> </>
<Button size="sm" onClick={() => setCreateOpen(true)}> }
<PlusIcon className="size-4" /> />
Новый интерфейс }
</Button> >
</> <div className="flex flex-col gap-4">
} <KpiStatGrid
/> aria-label="Сводка WireGuard"
items={[
{
id: "ifaces",
label: "Интерфейсов",
value: scopedIfaces.length,
icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Активных (UP)",
value: upIfaces,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "peers",
label: "Всего пиров",
value: totalPeers,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "online",
label: "Пиров онлайн",
value: `${onlinePeers}/${totalPeers}`,
icon: <KeyRoundIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
<div className="flex-1 overflow-y-auto p-6"> {failures.length > 0 ? (
<div className="flex flex-col gap-5"> <Alert variant="destructive">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <AlertCircleIcon />
{[ <AlertTitle>Не удалось опросить часть роутеров</AlertTitle>
{ label: "Интерфейсов", value: displayIfaces.length, icon: <ShieldCheckIcon className="size-4 text-muted-foreground" /> }, <AlertDescription>
{ label: "Активных (UP)", value: upIfaces, icon: <ActivityIcon className="size-4 text-emerald-500" /> }, {failures.map((f) => f.serverName ?? f.serverId).join(", ")}
{ label: "Всего пиров", value: totalPeers, icon: <UsersIcon className="size-4 text-sky-400" /> }, </AlertDescription>
{ label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: <KeyRoundIcon className="size-4 text-violet-400" /> }, </Alert>
].map((s) => ( ) : null}
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3"> {!isLive ? (
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground"> <Alert variant="info">
{s.icon} <InfoIcon />
</IconTile> <AlertTitle>Mock-режим</AlertTitle>
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> <AlertDescription>
<p className="text-muted-foreground text-sm font-medium">{s.label}</p> Переключитесь в live в настройках, чтобы применять изменения на MikroTik.
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p> </AlertDescription>
</Alert>
) : null}
<Tabs
value={workspaceTab}
onValueChange={(v) => setWorkspaceTab(v as WgWorkspaceTab)}
className="gap-3"
>
<TabsList variant="line">
<TabsTrigger value="interfaces" className="gap-1.5">
<ShieldCheckIcon className="size-3.5" />
Интерфейсы
</TabsTrigger>
<TabsTrigger value="peers" className="gap-1.5">
<UsersIcon className="size-3.5" />
Пиры
</TabsTrigger>
<TabsTrigger value="cli" className="gap-1.5">
<CodeXmlIcon className="size-3.5" />
CLI
</TabsTrigger>
</TabsList>
<TabsContent value="interfaces" className="mt-0 outline-none">
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, серверу, IP…"
countLabel={`${filteredIfaces.length} интерфейсов`}
segmented={{
value: statusFilter,
onChange: setStatusFilter,
options: [
{ value: "all", label: "Все", count: statusCounts.all },
{ value: "up", label: "UP", count: statusCounts.up },
{ value: "down", label: "DOWN", count: statusCounts.down },
],
}}
/>
<WireguardDataGrid
interfaces={filteredIfaces}
compactServer={compactServer}
emptyAction={emptyCreateAction}
onExport={(iface) => openExport(iface, "rsc")}
onAddPeer={setPeerIface}
onToggle={handleToggle}
onDelete={(iface) => setPendingDelete({ kind: "iface", iface })}
onDeletePeer={(iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })}
onExportPeer={(iface, peerId) => openExport(iface, "peer", peerId)}
/>
</DataPageCard>
</TabsContent>
<TabsContent value="peers" className="mt-0 outline-none">
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по ключу, IP, endpoint…"
countLabel={`${peerRows.length} пиров`}
/>
<WireguardPeersGrid
peers={peerRows}
compactServer={compactServer}
emptyAction={
scopedIfaces.length === 1 ? (
<Button size="sm" onClick={() => setPeerIface(scopedIfaces[0])}>
<PlusIcon className="size-4" />
Добавить пира
</Button>
) : emptyCreateAction
}
onDeletePeer={(row) => {
const iface = scopedIfaces.find((i) => i.id === row.ifaceId)
if (!iface) return
setPendingDelete({ kind: "peer", iface, peerId: row.id })
}}
onExportPeer={(row) => {
const iface = scopedIfaces.find((i) => i.id === row.ifaceId)
if (!iface) return
openExport(iface, "peer", row.id)
}}
/>
</DataPageCard>
</TabsContent>
<TabsContent value="cli" className="mt-0 outline-none">
<OpsPanel title="RouterOS 7 · /interface wireguard — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 gap-4 font-mono text-xs sm:grid-cols-3">
{[
{
title: "Создать интерфейс",
lines: [
"/interface wireguard add \\",
" name=wg0 \\",
" listen-port=13231 \\",
" mtu=1420",
],
},
{
title: "Добавить пира",
lines: [
"/interface wireguard peers add \\",
" interface=wg0 \\",
' public-key="<ключ>" \\',
" allowed-address=10.0.0.2/32 \\",
" endpoint-address=1.2.3.4 \\",
" persistent-keepalive=25",
],
},
{
title: "Назначить IP",
lines: [
"/ip address add \\",
" address=10.210.0.1/30 \\",
" interface=wg0",
"",
"# Статус:",
"/interface wireguard print",
],
},
].map((b) => (
<div key={b.title}>
<p className="mb-1.5 font-sans text-[11px] font-semibold uppercase tracking-wide text-foreground/80">
{b.title}
</p>
<pre className="overflow-x-auto rounded-md bg-muted p-2.5 text-[11px] leading-relaxed text-muted-foreground">
{b.lines.join("\n")}
</pre>
</div>
))}
</div> </div>
</FramePanel> </OpsPanel>
</Frame> </TabsContent>
))} </Tabs>
</div> </div>
</ServerRailLayout>
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
<ShieldCheckIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-sky-600 dark:text-sky-400">
WireGuard — live-интеграция RouterOS 7.x
</p>
<p className="text-muted-foreground text-xs mt-0.5">
{isLive
? "Опрос /interface/wireguard на включённых серверах. Создание, импорт .rsc/.conf и экспорт с роутера."
: "Сейчас mock-режим. Переключитесь в live в настройках, чтобы применять изменения на MikroTik."}
</p>
</div>
</div>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, серверу, IP…"
countLabel={`${filtered.length} интерфейсов`}
/>
<WireguardDataGrid
interfaces={filtered}
onExport={(iface) => {
setLiveExport(null)
setExportIface(iface)
}}
onAddPeer={setPeerIface}
onToggle={handleToggle}
onDelete={handleDelete}
onDeletePeer={handleDeletePeer}
onExportPeer={(iface) => {
setLiveExport(null)
setExportIface(iface)
}}
/>
</DataPageCard>
<OpsPanel title="RouterOS 7 · /interface wireguard — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
title: "Создать интерфейс",
lines: [
"/interface wireguard add \\",
" name=wg0 \\",
" listen-port=13231 \\",
" mtu=1420",
],
},
{
title: "Добавить пира",
lines: [
"/interface wireguard peers add \\",
" interface=wg0 \\",
' public-key="<ключ>" \\',
" allowed-address=10.0.0.2/32 \\",
" endpoint-address=1.2.3.4 \\",
" persistent-keepalive=25",
],
},
{
title: "Назначить IP",
lines: [
"/ip address add \\",
" address=10.210.0.1/30 \\",
" interface=wg0",
"",
"# Статус:",
"/interface wireguard print",
],
},
].map((b) => (
<div key={b.title}>
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">
{b.title}
</p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
</OpsPanel>
</div>
</div>
<WgCreateSheet <WgCreateSheet
open={createOpen} open={createOpen}
onOpenChange={setCreateOpen} onOpenChange={setCreateOpen}
servers={serverOptions} servers={serverOptions}
defaultServerId={sheetServerId}
busy={busy} busy={busy}
onSubmit={handleCreate} onSubmit={handleCreate}
/> />
@@ -539,6 +770,7 @@ export default function WireGuardPage() {
open={importOpen} open={importOpen}
onOpenChange={setImportOpen} onOpenChange={setImportOpen}
servers={serverOptions} servers={serverOptions}
defaultServerId={sheetServerId}
busy={busy} busy={busy}
onImport={handleImport} onImport={handleImport}
/> />
@@ -552,14 +784,44 @@ export default function WireGuardPage() {
<WgExportSheet <WgExportSheet
open={!!exportIface} open={!!exportIface}
iface={exportIface} iface={exportIface}
initialTab={exportInitialTab}
peerId={exportPeerId}
onClose={() => { onClose={() => {
setExportIface(null) setExportIface(null)
setExportPeerId(null)
setExportInitialTab("rsc")
setLiveExport(null) setLiveExport(null)
}} }}
liveContent={liveExport} liveContent={liveExport}
liveBusy={exportBusy} liveBusy={exportBusy}
onRequestLiveExport={isLive ? handleLiveExport : undefined} onRequestLiveExport={isLive ? handleLiveExport : undefined}
/> />
</div>
<AlertDialog open={!!pendingDelete} onOpenChange={(v) => { if (!v) setPendingDelete(null) }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<Trash2Icon />
</AlertDialogMedia>
<AlertDialogTitle>
{pendingDelete?.kind === "peer" ? "Удалить пира?" : "Удалить интерфейс?"}
</AlertDialogTitle>
<AlertDialogDescription>
{pendingDelete?.kind === "iface"
? `${pendingDelete.iface.name} на ${pendingDelete.iface.serverName}. Вместе с интерфейсом будут удалены связанные пиры на роутере.`
: pendingDelete
? `Пир на ${pendingDelete.iface.name} (${pendingDelete.iface.serverName}).`
: null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setPendingDelete(null)}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={() => void confirmDelete()}>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
) )
} }
+2 -1
View File
@@ -12,7 +12,8 @@
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts", "test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts" "test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts"
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
+2
View File
@@ -24,6 +24,7 @@ import certificatesRoutes from "./routes/certificates.js"
import systemDatabaseRoutes from "./routes/system-database.js" import systemDatabaseRoutes from "./routes/system-database.js"
import eventsRoutes from "./routes/events.js" import eventsRoutes from "./routes/events.js"
import wireguardRoutes from "./routes/wireguard.js" import wireguardRoutes from "./routes/wireguard.js"
import firewallRoutes from "./routes/firewall.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js" import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
export async function buildApp(opts?: { export async function buildApp(opts?: {
@@ -105,6 +106,7 @@ export async function buildApp(opts?: {
await app.register(systemDatabaseRoutes, { prefix: "/api" }) await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" }) await app.register(eventsRoutes, { prefix: "/api" })
await app.register(wireguardRoutes, { prefix: "/api" }) await app.register(wireguardRoutes, { prefix: "/api" })
await app.register(firewallRoutes, { prefix: "/api" })
if (opts?.startScheduler !== false) { if (opts?.startScheduler !== false) {
refreshScheduler() refreshScheduler()
+12
View File
@@ -17,6 +17,10 @@ assert.equal(
permissionForRequest("GET", "/api/system/database/backup"), permissionForRequest("GET", "/api/system/database/backup"),
"mm:settings:admin", "mm:settings:admin",
) )
assert.equal(
permissionForRequest("GET", "/api/traffic/servers/1/live"),
"mm:traffic:read",
)
assert.equal( assert.equal(
permissionForRequest("GET", "/api/unknown-thing"), permissionForRequest("GET", "/api/unknown-thing"),
"mm:dashboard:read", "mm:dashboard:read",
@@ -29,5 +33,13 @@ assert.equal(
permissionForRequest("POST", "/api/wireguard/interfaces"), permissionForRequest("POST", "/api/wireguard/interfaces"),
"mm:network:write", "mm:network:write",
) )
assert.equal(
permissionForRequest("GET", "/api/firewall/all"),
"mm:network:read",
)
assert.equal(
permissionForRequest("POST", "/api/firewall/rules"),
"mm:network:write",
)
console.log("permissions.test.ts: ok") console.log("permissions.test.ts: ok")
+4 -2
View File
@@ -142,7 +142,8 @@ const RULES: Rule[] = [
p.startsWith("/api/probes") || p.startsWith("/api/probes") ||
p.startsWith("/api/internet-path") || p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") || p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard"), p.startsWith("/api/wireguard") ||
p.startsWith("/api/firewall"),
permission: "mm:network:read", permission: "mm:network:read",
}, },
{ {
@@ -154,7 +155,8 @@ const RULES: Rule[] = [
p.startsWith("/api/probes") || p.startsWith("/api/probes") ||
p.startsWith("/api/internet-path") || p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") || p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard"), p.startsWith("/api/wireguard") ||
p.startsWith("/api/firewall"),
permission: "mm:network:write", permission: "mm:network:write",
}, },
] ]
+294
View File
@@ -0,0 +1,294 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { z } from "zod"
import { MikrotikClient, MikrotikError, encodeRosId, firewallRestPath } from "../services/mikrotik.js"
import { getEnabledServerById } from "../services/wireguard-live.js"
import { listFirewallAll } from "../services/firewall-live.js"
import type { FirewallFamily, FirewallTable } from "../types/server.js"
const FamilySchema = z.enum(["ip", "ip6"])
const TableSchema = z.enum(["filter", "nat", "mangle", "raw"])
const RuleKeySchema = z.object({
serverId: z.string().min(1),
family: FamilySchema,
table: TableSchema,
rosId: z.string().min(1),
})
const RuleWriteSchema = z.object({
serverId: z.string().min(1),
family: FamilySchema,
table: TableSchema,
rosId: z.string().min(1).optional(),
chain: z.string().min(1),
action: z.string().min(1),
protocol: z.string().optional(),
srcAddress: z.string().optional(),
dstAddress: z.string().optional(),
srcAddressList: z.string().optional(),
dstAddressList: z.string().optional(),
srcPort: z.string().optional(),
dstPort: z.string().optional(),
inInterface: z.string().optional(),
outInterface: z.string().optional(),
connectionState: z.string().optional(),
comment: z.string().optional(),
disabled: z.boolean().optional(),
log: z.boolean().optional(),
logPrefix: z.string().optional(),
tlsHost: z.string().optional(),
layer7Proto: z.string().optional(),
})
const RulePatchSchema = RuleKeySchema.extend({
disabled: z.boolean(),
})
const RuleMoveSchema = RuleKeySchema.extend({
destinationRosId: z.string().min(1).optional(),
})
const AddressKeySchema = z.object({
serverId: z.string().min(1),
family: FamilySchema,
rosId: z.string().min(1),
})
const AddressWriteSchema = z.object({
serverId: z.string().min(1),
family: FamilySchema,
rosId: z.string().min(1).optional(),
list: z.string().min(1),
address: z.string().min(1),
comment: z.string().optional(),
timeout: z.string().optional(),
disabled: z.boolean().optional(),
})
const AddressPatchSchema = AddressKeySchema.extend({
disabled: z.boolean(),
})
function toRosBody(obj: Record<string, string | undefined>): Record<string, string> {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(obj)) {
if (v !== undefined && v !== "") out[k] = v
}
return out
}
function ruleToRos(d: z.infer<typeof RuleWriteSchema>): Record<string, string> {
return toRosBody({
chain: d.chain,
action: d.action,
protocol: d.protocol && d.protocol !== "all" ? d.protocol : undefined,
"src-address": d.srcAddress,
"dst-address": d.dstAddress,
"src-address-list": d.srcAddressList,
"dst-address-list": d.dstAddressList,
"src-port": d.srcPort,
"dst-port": d.dstPort,
"in-interface": d.inInterface,
"out-interface": d.outInterface,
"connection-state": d.connectionState,
comment: d.comment,
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
log: d.log === true ? "yes" : d.log === false ? "no" : undefined,
"log-prefix": d.logPrefix,
"tls-host": d.tlsHost,
"layer7-protocol": d.layer7Proto,
})
}
function addressToRos(d: z.infer<typeof AddressWriteSchema>): Record<string, string> {
return toRosBody({
list: d.list,
address: d.address,
comment: d.comment,
timeout: d.timeout,
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
})
}
function rosErr(e: unknown): string {
if (e instanceof MikrotikError) return e.message
if (e instanceof Error) return e.message
return String(e)
}
function requireServer(serverId: string) {
return getEnabledServerById(serverId)
}
const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/firewall/all", async (_req, reply) => {
const data = await listFirewallAll()
return reply.send(data)
})
app.post("/firewall/rules", async (req, reply) => {
const parsed = RuleWriteSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
const path = firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)
try {
await client.put(path, ruleToRos(body))
return reply.status(201).send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.put("/firewall/rules", async (req, reply) => {
const parsed = RuleWriteSchema.extend({ rosId: z.string().min(1) }).safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)}/${encodeRosId(body.rosId)}`
try {
await client.patch(path, ruleToRos(body))
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.patch("/firewall/rules", async (req, reply) => {
const parsed = RulePatchSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}`
try {
await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.delete("/firewall/rules", async (req, reply) => {
const parsed = RuleKeySchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}`
try {
await client.delete(path)
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.post("/firewall/rules/move", async (req, reply) => {
const parsed = RuleMoveSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family, body.table)}/move`
try {
await client.post(path, {
numbers: body.rosId,
...(body.destinationRosId ? { destination: body.destinationRosId } : {}),
})
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.post("/firewall/address-lists", async (req, reply) => {
const parsed = AddressWriteSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
await client.put(firewallRestPath(body.family, "address-list"), addressToRos(body))
return reply.status(201).send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.put("/firewall/address-lists", async (req, reply) => {
const parsed = AddressWriteSchema.extend({ rosId: z.string().min(1) }).safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
try {
await client.patch(path, addressToRos(body))
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.patch("/firewall/address-lists", async (req, reply) => {
const parsed = AddressPatchSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
try {
await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.delete("/firewall/address-lists", async (req, reply) => {
const parsed = AddressKeySchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
try {
await client.delete(path)
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
}
export default firewallRoutes
+215 -95
View File
@@ -1,3 +1,4 @@
import { env } from "../config.js"
import { desc, eq } from "drizzle-orm" import { desc, eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js" import { db } from "../db/index.js"
@@ -12,6 +13,15 @@ import {
import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js" import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js"
import { refreshScheduler } from "../services/scheduler.js" import { refreshScheduler } from "../services/scheduler.js"
import { appendEvent } from "../modules/events/service/events-service.js" import { appendEvent } from "../modules/events/service/events-service.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { getEnabledServerById } from "../services/wireguard-live.js"
import {
bpsToMbps,
buildTrafficFromSamples,
isLoopbackName,
parseMonitorTraffic,
rateBpsFromDelta,
} from "../services/traffic-rate.js"
type SnapshotRow = typeof serverSnapshots.$inferSelect type SnapshotRow = typeof serverSnapshots.$inferSelect
@@ -40,6 +50,9 @@ interface TrafficInterfaceDto {
txNow: number txNow: number
} }
const LIVE_TICK_MS = 1500
const LIVE_ROS_TIMEOUT_MS = 4000
function latestSnapshot(serverId: number): SnapshotRow | undefined { function latestSnapshot(serverId: number): SnapshotRow | undefined {
return db return db
.select() .select()
@@ -61,14 +74,6 @@ function rangeToMinutes(range: string | undefined): number {
} }
} }
function toSeries(values: number[], target = 60): number[] {
if (values.length === 0) return Array(target).fill(0)
if (values.length === target) return values
if (values.length > target) return values.slice(values.length - target)
const head = Array(target - values.length).fill(values[0] ?? 0)
return [...head, ...values]
}
function buildServerTraffic( function buildServerTraffic(
s: typeof servers.$inferSelect, s: typeof servers.$inferSelect,
status: TrafficServerDto["status"], status: TrafficServerDto["status"],
@@ -82,88 +87,135 @@ function buildServerTraffic(
running: boolean running: boolean
disabled: boolean disabled: boolean
}>, }>,
rangeStartMs: number,
rangeEndMs: number,
onlyInterface?: string, onlyInterface?: string,
): TrafficServerDto { ): TrafficServerDto {
const filteredRows = onlyInterface const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, onlyInterface)
? rows.filter((r) => r.interfaceName === onlyInterface)
: rows
if (filteredRows.length === 0) {
return {
id: String(s.id),
name: s.name || s.host,
site: s.site || "—",
country: s.country || "UN",
status,
rxNow: 0,
txNow: 0,
rxPeak: 0,
txPeak: 0,
rxTotal: 0,
txTotal: 0,
sessions: 0,
rxSeries: Array(60).fill(0),
txSeries: Array(60).fill(0),
}
}
const bySampleTs = new Map<string, { rx: number; tx: number }>()
const byIface = new Map<string, typeof filteredRows>()
for (const r of filteredRows) {
const ts = r.sampledAt
const cur = bySampleTs.get(ts) ?? { rx: 0, tx: 0 }
cur.rx += Math.max(0, r.rxBps) / 1_000_000
cur.tx += Math.max(0, r.txBps) / 1_000_000
bySampleTs.set(ts, cur)
const arr = byIface.get(r.interfaceName) ?? []
arr.push(r)
byIface.set(r.interfaceName, arr)
}
const seriesPoints = [...bySampleTs.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([, v]) => ({ rx: Math.round(v.rx), tx: Math.round(v.tx) }))
const rxSeries = toSeries(seriesPoints.map((p) => p.rx))
const txSeries = toSeries(seriesPoints.map((p) => p.tx))
const rxNow = rxSeries[rxSeries.length - 1] ?? 0
const txNow = txSeries[txSeries.length - 1] ?? 0
const rxPeak = rxSeries.reduce((m, v) => Math.max(m, v), 0)
const txPeak = txSeries.reduce((m, v) => Math.max(m, v), 0)
let rxBytesDelta = 0
let txBytesDelta = 0
let sessions = 0
for (const arr of byIface.values()) {
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
const first = sorted[0]
const last = sorted[sorted.length - 1]
if (first && last) {
const dRx = last.rxBytes - first.rxBytes
const dTx = last.txBytes - first.txBytes
rxBytesDelta += dRx >= 0 ? dRx : last.rxBytes
txBytesDelta += dTx >= 0 ? dTx : last.txBytes
if (last.running && !last.disabled) sessions += 1
}
}
return { return {
id: String(s.id), id: String(s.id),
name: s.name || s.host, name: s.name || s.host,
site: s.site || "—", site: s.site || "—",
country: s.country || "UN", country: s.country || "UN",
status, status,
rxNow, rxNow: built.rxNow,
txNow, txNow: built.txNow,
rxPeak, rxPeak: built.rxPeak,
txPeak, txPeak: built.txPeak,
rxTotal: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)), rxTotal: built.rxTotalGiB,
txTotal: Number((txBytesDelta / (1024 ** 3)).toFixed(1)), txTotal: built.txTotalGiB,
sessions, sessions: built.sessions,
rxSeries, rxSeries: built.rxSeries,
txSeries, txSeries: built.txSeries,
} }
} }
function snapshotStatus(serverId: number): TrafficServerDto["status"] {
const snap = latestSnapshot(serverId)
return snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
}
function ifaceNowMbps(
prev: { rxBytes: number; txBytes: number; sampledAt: string } | undefined,
last: { rxBytes: number; txBytes: number; sampledAt: string; rxBps: number; txBps: number },
): { rxNow: number; txNow: number } {
if (!prev) {
return { rxNow: bpsToMbps(last.rxBps), txNow: bpsToMbps(last.txBps) }
}
const t0 = Date.parse(prev.sampledAt)
const t1 = Date.parse(last.sampledAt)
const rxBps = rateBpsFromDelta(prev.rxBytes, last.rxBytes, t0, t1)
const txBps = rateBpsFromDelta(prev.txBytes, last.txBytes, t0, t1)
return {
rxNow: bpsToMbps(rxBps ?? last.rxBps),
txNow: bpsToMbps(txBps ?? last.txBps),
}
}
function writeSse(raw: NodeJS.WritableStream, event: string, data: unknown) {
raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
}
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new Error("aborted"))
return
}
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort)
resolve()
}, ms)
const onAbort = () => {
clearTimeout(timer)
reject(new Error("aborted"))
}
signal.addEventListener("abort", onAbort, { once: true })
})
}
function flattenMonitor(raw: unknown): unknown[] {
if (Array.isArray(raw)) return raw
if (raw != null) return [raw]
return []
}
async function listRunningIfaceNames(client: MikrotikClient): Promise<string[]> {
const ifaces = await client.get<Array<{ name?: string; running?: string; disabled?: string }>>(
"/interface",
LIVE_ROS_TIMEOUT_MS,
)
return ifaces
.filter((i) => (i.running ?? "false") === "true"
&& (i.disabled ?? "false") !== "true"
&& !isLoopbackName(i.name ?? ""))
.map((i) => i.name ?? "")
.filter(Boolean)
}
async function monitorTrafficOnce(
client: MikrotikClient,
onlyInterface: string | undefined,
cache: { names: string[]; joinedFailed: boolean },
signal: AbortSignal,
): Promise<unknown> {
if (onlyInterface) {
return client.post(
"/interface/monitor-traffic",
{ interface: onlyInterface, once: "" },
LIVE_ROS_TIMEOUT_MS,
signal,
)
}
if (cache.names.length === 0) {
cache.names = await listRunningIfaceNames(client)
}
if (cache.names.length === 0) return []
if (!cache.joinedFailed) {
try {
return await client.post(
"/interface/monitor-traffic",
{ interface: cache.names.join(","), once: "" },
LIVE_ROS_TIMEOUT_MS,
signal,
)
} catch {
cache.joinedFailed = true
}
}
const chunks = await Promise.all(
cache.names.map((name) =>
client.post(
"/interface/monitor-traffic",
{ interface: name, once: "" },
LIVE_ROS_TIMEOUT_MS,
signal,
).then(flattenMonitor).catch(() => [] as unknown[]),
),
)
return chunks.flat()
}
const trafficRoutes: FastifyPluginAsyncZod = async (app) => { const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/traffic/settings", async (_req, reply) => { app.get("/traffic/settings", async (_req, reply) => {
const settings = getTrafficSettings() const settings = getTrafficSettings()
@@ -243,20 +295,77 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/traffic/servers", async (req, reply) => { app.get("/traffic/servers", async (req, reply) => {
const q = req.query as { range?: string } const q = req.query as { range?: string }
const minutes = rangeToMinutes(q.range) const minutes = rangeToMinutes(q.range)
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString() const rangeEndMs = Date.now()
const rangeStartMs = rangeEndMs - minutes * 60_000
const sinceIso = new Date(rangeStartMs).toISOString()
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all() const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const data = allServers.map((s): TrafficServerDto => { const data = allServers.map((s): TrafficServerDto => {
const snap = latestSnapshot(s.id)
const status: TrafficServerDto["status"] =
snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
const rows = readServerSamplesInRange(s.id, sinceIso) const rows = readServerSamplesInRange(s.id, sinceIso)
return buildServerTraffic(s, status, rows) return buildServerTraffic(s, snapshotStatus(s.id), rows, rangeStartMs, rangeEndMs)
}) })
return reply.send({ servers: data }) return reply.send({ servers: data })
}) })
app.get("/traffic/servers/:id/live", async (req, reply) => {
const p = req.params as { id?: string | number }
const q = req.query as { iface?: string }
const server = getEnabledServerById(p.id ?? "")
if (!server || !server.enabled) return reply.status(404).send({ error: "Server not found" })
const onlyInterface = q.iface && q.iface !== "__all__" ? q.iface : undefined
const abort = new AbortController()
const onClose = () => abort.abort()
req.raw.on("close", onClose)
reply.hijack()
req.raw.setTimeout(0)
reply.raw.setTimeout(0)
const origin = typeof req.headers.origin === "string" ? req.headers.origin : ""
const allowed = env.CORS_ORIGIN
const sseHeaders: Record<string, string> = {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
}
if (origin && (allowed === "*" || allowed === origin)) {
sseHeaders["Access-Control-Allow-Origin"] = origin
sseHeaders["Access-Control-Allow-Credentials"] = "true"
sseHeaders["Access-Control-Allow-Headers"] = "Authorization, Accept"
sseHeaders.Vary = "Origin"
}
reply.raw.writeHead(200, sseHeaders)
reply.raw.write(":\n\n")
const client = MikrotikClient.fromServer(server)
const cache = { names: onlyInterface ? [onlyInterface] : [] as string[], joinedFailed: false }
try {
while (!abort.signal.aborted) {
try {
const raw = await monitorTrafficOnce(client, onlyInterface, cache, abort.signal)
const sample = parseMonitorTraffic(raw, { onlyInterface })
writeSse(reply.raw, "sample", sample)
} catch (error) {
if (abort.signal.aborted) break
const msg = error instanceof Error ? error.message : String(error)
writeSse(reply.raw, "error", { error: msg })
}
await sleep(LIVE_TICK_MS, abort.signal)
}
} catch {
/* abort / disconnect */
} finally {
req.raw.off("close", onClose)
try {
reply.raw.end()
} catch {
/* already closed */
}
}
})
app.get("/traffic/servers/:id/interfaces", async (req, reply) => { app.get("/traffic/servers/:id/interfaces", async (req, reply) => {
const p = req.params as { id?: string | number } const p = req.params as { id?: string | number }
const serverId = Number.parseInt(String(p.id ?? ""), 10) const serverId = Number.parseInt(String(p.id ?? ""), 10)
@@ -270,6 +379,7 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
const rows = readServerSamplesInRange(serverId, sinceIso) const rows = readServerSamplesInRange(serverId, sinceIso)
const byIface = new Map<string, typeof rows>() const byIface = new Map<string, typeof rows>()
for (const r of rows) { for (const r of rows) {
if (isLoopbackName(r.interfaceName)) continue
const arr = byIface.get(r.interfaceName) ?? [] const arr = byIface.get(r.interfaceName) ?? []
arr.push(r) arr.push(r)
byIface.set(r.interfaceName, arr) byIface.set(r.interfaceName, arr)
@@ -277,12 +387,17 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
const interfaces: TrafficInterfaceDto[] = [...byIface.entries()].map(([name, arr]) => { const interfaces: TrafficInterfaceDto[] = [...byIface.entries()].map(([name, arr]) => {
const sorted = arr.sort((a, b) => a.sampledAt.localeCompare(b.sampledAt)) const sorted = arr.sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
const last = sorted[sorted.length - 1] const last = sorted[sorted.length - 1]
const prev = sorted[sorted.length - 2]
if (!last) {
return { name, running: false, disabled: true, rxNow: 0, txNow: 0 }
}
const now = ifaceNowMbps(prev, last)
return { return {
name, name,
running: Boolean(last?.running), running: Boolean(last.running),
disabled: Boolean(last?.disabled), disabled: Boolean(last.disabled),
rxNow: Math.round((last?.rxBps ?? 0) / 1_000_000), rxNow: now.rxNow,
txNow: Math.round((last?.txBps ?? 0) / 1_000_000), txNow: now.txNow,
} }
}).sort((a, b) => (b.rxNow + b.txNow) - (a.rxNow + a.txNow)) }).sort((a, b) => (b.rxNow + b.txNow) - (a.rxNow + a.txNow))
@@ -298,15 +413,20 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
if (!server) return reply.status(404).send({ error: "Server not found" }) if (!server) return reply.status(404).send({ error: "Server not found" })
const minutes = rangeToMinutes(q.range) const minutes = rangeToMinutes(q.range)
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString() const rangeEndMs = Date.now()
const rangeStartMs = rangeEndMs - minutes * 60_000
const sinceIso = new Date(rangeStartMs).toISOString()
const rows = readServerSamplesInRange(server.id, sinceIso) const rows = readServerSamplesInRange(server.id, sinceIso)
const snap = latestSnapshot(server.id) const data = buildServerTraffic(
const status: TrafficServerDto["status"] = server,
snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded") snapshotStatus(server.id),
const data = buildServerTraffic(server, status, rows, q.iface && q.iface !== "__all__" ? q.iface : undefined) rows,
rangeStartMs,
rangeEndMs,
q.iface && q.iface !== "__all__" ? q.iface : undefined,
)
return reply.send({ server: data }) return reply.send({ server: data })
}) })
} }
export default trafficRoutes export default trafficRoutes
+198
View File
@@ -0,0 +1,198 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import {
MikrotikClient,
firewallRestPath,
} from "./mikrotik.js"
import type {
FirewallFamily,
FirewallTable,
RosFirewallAddressList,
RosFirewallFilter,
} from "../types/server.js"
type ServerRow = typeof servers.$inferSelect
export interface FirewallRuleDto {
id: string
rosId: string
serverId: string
serverName: string
family: FirewallFamily
table: FirewallTable
chain: string
action: string
proto: string
src: string
dst: string
port: string
iface: string
comment: string
enabled: boolean
hits: number
log: boolean
logPrefix: string
tlsHost?: string
layer7Proto?: string
}
export interface FirewallAddressListDto {
id: string
rosId: string
serverId: string
serverName: string
family: FirewallFamily
list: string
address: string
comment: string
disabled: boolean
timeout?: string
}
const TABLES: FirewallTable[] = ["filter", "nat", "mangle", "raw"]
const FAMILIES: FirewallFamily[] = ["ip", "ip6"]
function dash(v: string | undefined): string {
const s = v?.trim() ?? ""
return s.length > 0 ? s : "—"
}
function rosDisabled(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
function parseHits(raw: RosFirewallFilter): number {
const n = Number.parseInt(raw.packets ?? "0", 10)
return Number.isFinite(n) ? n : 0
}
export function ruleUiId(
serverId: string | number,
family: FirewallFamily,
table: FirewallTable,
rosId: string,
): string {
return `${serverId}:${family}:${table}:${rosId}`
}
export function addressUiId(
serverId: string | number,
family: FirewallFamily,
rosId: string,
): string {
return `${serverId}:${family}:address-list:${rosId}`
}
export function mapFirewallRule(
server: ServerRow,
family: FirewallFamily,
table: FirewallTable,
raw: RosFirewallFilter,
idx: number,
): FirewallRuleDto {
const rosId = raw[".id"] || `*${idx}`
const src = raw["src-address"] || raw["src-address-list"]
const dst = raw["dst-address"] || raw["dst-address-list"]
const port = raw["dst-port"] || raw["src-port"]
const iface = raw["in-interface"] || raw["out-interface"]
return {
id: ruleUiId(server.id, family, table, rosId),
rosId,
serverId: String(server.id),
serverName: server.name || server.host,
family,
table,
chain: raw.chain || "",
action: raw.action || "",
proto: raw.protocol || "all",
src: dash(src),
dst: dash(dst),
port: dash(port),
iface: dash(iface),
comment: raw.comment ?? "",
enabled: !rosDisabled(raw.disabled),
hits: parseHits(raw),
log: raw.log === "true" || raw.log === "yes",
logPrefix: raw["log-prefix"] ?? "",
tlsHost: raw["tls-host"],
layer7Proto: raw["layer7-protocol"],
}
}
export function mapAddressList(
server: ServerRow,
family: FirewallFamily,
raw: RosFirewallAddressList,
idx: number,
): FirewallAddressListDto {
const rosId = raw[".id"] || `*${idx}`
return {
id: addressUiId(server.id, family, rosId),
rosId,
serverId: String(server.id),
serverName: server.name || server.host,
family,
list: raw.list || "",
address: raw.address || "",
comment: raw.comment ?? "",
disabled: rosDisabled(raw.disabled),
timeout: raw.timeout,
}
}
async function safeGet<T>(fn: () => Promise<T[]>, fallback: T[] = []): Promise<T[]> {
try {
const rows = await fn()
return Array.isArray(rows) ? rows : fallback
} catch {
return fallback
}
}
export async function fetchServerFirewall(server: ServerRow): Promise<{
rules: FirewallRuleDto[]
addressLists: FirewallAddressListDto[]
}> {
const client = MikrotikClient.fromServer(server)
const ruleJobs = FAMILIES.flatMap((family) =>
TABLES.map(async (table) => {
const raw = await safeGet(() => client.getFirewallRules(family, table))
return raw.map((row, idx) => mapFirewallRule(server, family, table, row, idx))
}),
)
const listJobs = FAMILIES.map(async (family) => {
const raw = await safeGet(() => client.getFirewallAddressList(family))
return raw.map((row, idx) => mapAddressList(server, family, row, idx))
})
const [ruleChunks, listChunks] = await Promise.all([
Promise.all(ruleJobs),
Promise.all(listJobs),
])
return {
rules: ruleChunks.flat(),
addressLists: listChunks.flat(),
}
}
export async function listFirewallAll(): Promise<{
rules: FirewallRuleDto[]
addressLists: FirewallAddressListDto[]
}> {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const perServer = await Promise.all(
allServers.map(async (server) => {
try {
return await fetchServerFirewall(server)
} catch {
return { rules: [] as FirewallRuleDto[], addressLists: [] as FirewallAddressListDto[] }
}
}),
)
return {
rules: perServer.flatMap((r) => r.rules),
addressLists: perServer.flatMap((r) => r.addressLists),
}
}
export { firewallRestPath, FAMILIES, TABLES }
+24 -2
View File
@@ -7,7 +7,8 @@ import type {
RosBgpSession, RosBgpSession,
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance, RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
RosBfdSession, RosBfdSession,
RosIpRoute, RosFirewallFilter, RosLogEntry, RosPingResult, RosIpRoute, RosFirewallFilter, RosFirewallAddressList, RosLogEntry, RosPingResult,
FirewallFamily, FirewallTable,
} from "../types/server.js" } from "../types/server.js"
// ── connection params ───────────────────────────────────────────────────────── // ── connection params ─────────────────────────────────────────────────────────
@@ -338,6 +339,19 @@ function matchesUploadedFile(entryName: string, requested: string): boolean {
|| entryName.endsWith(`/${base}`) || entryName.endsWith(`/${base}`)
} }
export function firewallRestPath(
family: FirewallFamily,
table: FirewallTable | "address-list",
): string {
const root = family === "ip6" ? "/ipv6/firewall" : "/ip/firewall"
return `${root}/${table}`
}
export function encodeRosId(rosId: string): string {
const id = rosId.startsWith("*") ? rosId : `*${rosId.replace(/^\*/, "")}`
return encodeURIComponent(id)
}
// ── MikrotikClient ───────────────────────────────────────────────────────────── // ── MikrotikClient ─────────────────────────────────────────────────────────────
export class MikrotikClient { export class MikrotikClient {
@@ -461,7 +475,15 @@ export class MikrotikClient {
} }
async getFirewallFilters(): Promise<RosFirewallFilter[]> { async getFirewallFilters(): Promise<RosFirewallFilter[]> {
return this.get<RosFirewallFilter[]>("/ip/firewall/filter") return this.getFirewallRules("ip", "filter")
}
async getFirewallRules(family: FirewallFamily, table: FirewallTable): Promise<RosFirewallFilter[]> {
return this.get<RosFirewallFilter[]>(firewallRestPath(family, table))
}
async getFirewallAddressList(family: FirewallFamily): Promise<RosFirewallAddressList[]> {
return this.get<RosFirewallAddressList[]>(firewallRestPath(family, "address-list"))
} }
async getLogs(limit = 50): Promise<RosLogEntry[]> { async getLogs(limit = 50): Promise<RosLogEntry[]> {
+65 -23
View File
@@ -1,9 +1,10 @@
import { and, asc, eq, gte, lt } from "drizzle-orm" import { and, asc, desc, eq, gte, lt } from "drizzle-orm"
import { db } from "../db/index.js" import { db } from "../db/index.js"
import { servers, trafficSamples, trafficSettings } from "../db/schema.js" import { servers, trafficSamples, trafficSettings } from "../db/schema.js"
import type { TrafficRunSnapshot } from "../types/scheduler-run-snapshot.js" import type { TrafficRunSnapshot } from "../types/scheduler-run-snapshot.js"
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js" import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
import { MikrotikClient } from "./mikrotik.js" import { MikrotikClient } from "./mikrotik.js"
import { bpsToMbps, rateBpsFromDelta, shouldIncludeIface } from "./traffic-rate.js"
interface RosIfaceTraffic { interface RosIfaceTraffic {
name?: string name?: string
@@ -50,6 +51,31 @@ function cleanupOldSamples(retentionDays: number) {
.run() .run()
} }
function readPreviousWave(serverId: number): Map<string, { rxBytes: number; txBytes: number; sampledAt: string }> {
const last = db
.select({ sampledAt: trafficSamples.sampledAt })
.from(trafficSamples)
.where(eq(trafficSamples.serverId, serverId))
.orderBy(desc(trafficSamples.sampledAt))
.limit(1)
.all()[0]
if (!last) return new Map()
const rows = db
.select({
interfaceName: trafficSamples.interfaceName,
rxBytes: trafficSamples.rxBytes,
txBytes: trafficSamples.txBytes,
sampledAt: trafficSamples.sampledAt,
})
.from(trafficSamples)
.where(and(
eq(trafficSamples.serverId, serverId),
eq(trafficSamples.sampledAt, last.sampledAt),
))
.all()
return new Map(rows.map((r) => [r.interfaceName, r]))
}
export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> { export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
const sampledAt = new Date().toISOString() const sampledAt = new Date().toISOString()
if (collecting) { if (collecting) {
@@ -80,26 +106,42 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
try { try {
const client = MikrotikClient.fromServer(srv) const client = MikrotikClient.fromServer(srv)
const ifaces = await client.get<RosIfaceTraffic[]>("/interface") const ifaces = await client.get<RosIfaceTraffic[]>("/interface")
let sumRx = 0 const prevWave = readPreviousWave(srv.id)
let sumTx = 0 const nowMs = Date.parse(now)
for (const i of ifaces) { let sumRxMbps = 0
sumRx += toNum(i["rx-bits-per-second"]) / 1_000_000 let sumTxMbps = 0
sumTx += toNum(i["tx-bits-per-second"]) / 1_000_000 const rows = ifaces.map((i) => {
} const interfaceName = i.name ?? "unknown"
if (ifaces.length > 0) { const rxBytes = toNum(i["rx-byte"])
db.insert(trafficSamples).values( const txBytes = toNum(i["tx-byte"])
ifaces.map((i) => ({ const running = (i.running ?? "false") === "true"
serverId: srv.id, const disabled = (i.disabled ?? "false") === "true"
interfaceName: i.name ?? "unknown", const prev = prevWave.get(interfaceName)
sampledAt: now, const prevMs = prev ? Date.parse(prev.sampledAt) : NaN
rxBytes: toNum(i["rx-byte"]), const rxBps = prev && Number.isFinite(prevMs)
txBytes: toNum(i["tx-byte"]), ? (rateBpsFromDelta(prev.rxBytes, rxBytes, prevMs, nowMs) ?? 0)
rxBps: toNum(i["rx-bits-per-second"]), : 0
txBps: toNum(i["tx-bits-per-second"]), const txBps = prev && Number.isFinite(prevMs)
running: (i.running ?? "false") === "true", ? (rateBpsFromDelta(prev.txBytes, txBytes, prevMs, nowMs) ?? 0)
disabled: (i.disabled ?? "false") === "true", : 0
})), if (shouldIncludeIface(interfaceName, running, disabled)) {
).run() sumRxMbps += bpsToMbps(rxBps)
sumTxMbps += bpsToMbps(txBps)
}
return {
serverId: srv.id,
interfaceName,
sampledAt: now,
rxBytes,
txBytes,
rxBps,
txBps,
running,
disabled,
}
})
if (rows.length > 0) {
db.insert(trafficSamples).values(rows).run()
} }
snapshot.servers.push({ snapshot.servers.push({
serverId: srv.id, serverId: srv.id,
@@ -107,8 +149,8 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
host: srv.host, host: srv.host,
ok: true, ok: true,
interfaces: ifaces.length, interfaces: ifaces.length,
sumRxMbps: Math.round(sumRx), sumRxMbps: Math.round(sumRxMbps * 1000) / 1000,
sumTxMbps: Math.round(sumTx), sumTxMbps: Math.round(sumTxMbps * 1000) / 1000,
}) })
} catch (err) { } catch (err) {
snapshot.servers.push({ snapshot.servers.push({
+76
View File
@@ -0,0 +1,76 @@
import assert from "node:assert/strict"
import {
bpsToMbps,
bucketAvg,
buildTrafficFromSamples,
isLoopbackName,
parseMonitorTraffic,
rateBpsFromDelta,
shouldIncludeIface,
type TrafficSampleLike,
} from "./traffic-rate.js"
assert.equal(isLoopbackName("lo"), true)
assert.equal(isLoopbackName("loopback"), true)
assert.equal(isLoopbackName("ether1"), false)
assert.equal(shouldIncludeIface("lo", true, false), false)
assert.equal(shouldIncludeIface("ether1", true, false), true)
assert.equal(shouldIncludeIface("ether1", false, false), false)
assert.equal(shouldIncludeIface("ether1", true, true), false)
assert.equal(shouldIncludeIface("lo", true, false, "lo"), true)
assert.equal(rateBpsFromDelta(1000, 2000, 0, 1000), 8000)
assert.equal(rateBpsFromDelta(1000, 500, 0, 1000), null)
assert.equal(rateBpsFromDelta(1000, 2000, 1000, 1000), null)
assert.equal(bpsToMbps(1_500_000), 1.5)
assert.equal(bpsToMbps(400_000), 0.4)
const buckets = bucketAvg(
[
{ t: 0, v: 10 },
{ t: 1000, v: 20 },
],
0,
1000,
4,
)
assert.equal(buckets.length, 4)
assert.equal(buckets[0], 10)
assert.equal(buckets[3], 20)
assert.equal(buckets[1], 0)
assert.equal(buckets[2], 0)
const t0 = "2026-09-06T10:00:00.000Z"
const t1 = "2026-09-06T10:00:30.000Z"
const t2 = "2026-09-06T10:01:00.000Z"
const start = Date.parse(t0)
const end = Date.parse(t2)
const samples: TrafficSampleLike[] = [
{ interfaceName: "ether1", sampledAt: t0, rxBytes: 1_000_000, txBytes: 500_000, rxBps: 0, txBps: 0, running: true, disabled: false },
{ interfaceName: "ether1", sampledAt: t1, rxBytes: 1_000_000 + 3_750_000, txBytes: 500_000 + 1_875_000, rxBps: 0, txBps: 0, running: true, disabled: false },
{ interfaceName: "ether1", sampledAt: t2, rxBytes: 100, txBytes: 50, rxBps: 0, txBps: 0, running: true, disabled: false },
{ interfaceName: "lo", sampledAt: t0, rxBytes: 0, txBytes: 0, rxBps: 0, txBps: 0, running: true, disabled: false },
{ interfaceName: "lo", sampledAt: t1, rxBytes: 9_000_000, txBytes: 9_000_000, rxBps: 0, txBps: 0, running: true, disabled: false },
]
const built = buildTrafficFromSamples(samples, start, end)
assert.ok(built.rxPeak > 0, "peak RX from delta")
assert.equal(built.rxNow, 1, "last valid delta after reset skip")
assert.ok(built.rxSeries.some((v) => v > 0), "bucket series not flat")
assert.ok(built.rxPeak <= 1.1, "lo excluded from peak")
const live = parseMonitorTraffic([
{ name: "ether1", "rx-bits-per-second": "2000000", "tx-bits-per-second": "500000" },
{ name: "lo", "rx-bits-per-second": "8000000", "tx-bits-per-second": "8000000" },
])
assert.equal(live.rxMbps, 2)
assert.equal(live.txMbps, 0.5)
const onceOnly = parseMonitorTraffic(
{ name: "ether1", "rx-bits-per-second": "1000000", "tx-bits-per-second": "0" },
{ onlyInterface: "ether1" },
)
assert.equal(onceOnly.rxMbps, 1)
console.log("traffic-rate tests ok")
+221
View File
@@ -0,0 +1,221 @@
/** Чистые формулы трафика: дельты счётчиков, корзины series, monitor-traffic. */
export const SERIES_POINTS = 60
export interface TrafficSampleLike {
interfaceName: string
sampledAt: string
rxBytes: number
txBytes: number
rxBps: number
txBps: number
running: boolean
disabled: boolean
}
export interface RatePoint {
t: number
rxMbps: number
txMbps: number
}
export interface BuiltTrafficSeries {
rxNow: number
txNow: number
rxPeak: number
txPeak: number
rxTotalGiB: number
txTotalGiB: number
sessions: number
rxSeries: number[]
txSeries: number[]
}
export interface MonitorLiveSample {
rxMbps: number
txMbps: number
at: string
}
export function isLoopbackName(name: string): boolean {
return /^(lo|loopback)(\d+)?$/i.test(name.trim())
}
export function shouldIncludeIface(
name: string,
running: boolean,
disabled: boolean,
onlyInterface?: string,
): boolean {
if (onlyInterface) return name === onlyInterface
if (isLoopbackName(name)) return false
return running && !disabled
}
export function bpsToMbps(bps: number): number {
if (!Number.isFinite(bps) || bps <= 0) return 0
return Math.round((bps / 1_000_000) * 1000) / 1000
}
/** bits/s из соседних счётчиков. null = нельзя (Δt≤0 или сброс). */
export function rateBpsFromDelta(
prevBytes: number,
nextBytes: number,
prevAtMs: number,
nextAtMs: number,
): number | null {
const dtSec = (nextAtMs - prevAtMs) / 1000
if (!(dtSec > 0) || !Number.isFinite(dtSec)) return null
if (nextBytes < prevBytes) return null
return Math.round(((nextBytes - prevBytes) * 8) / dtSec)
}
export function bucketAvg(
points: Array<{ t: number; v: number }>,
rangeStartMs: number,
rangeEndMs: number,
target = SERIES_POINTS,
): number[] {
const buckets = Array.from({ length: target }, () => 0)
const counts = Array.from({ length: target }, () => 0)
const span = rangeEndMs - rangeStartMs
if (span <= 0 || points.length === 0) return buckets
for (const p of points) {
const ratio = (p.t - rangeStartMs) / span
const i = Math.min(target - 1, Math.max(0, Math.floor(ratio * target)))
buckets[i] += p.v
counts[i] += 1
}
return buckets.map((sum, i) => (counts[i] > 0 ? sum / counts[i] : 0))
}
function parseIsoMs(iso: string): number {
const t = Date.parse(iso)
return Number.isFinite(t) ? t : 0
}
export function buildTrafficFromSamples(
rows: TrafficSampleLike[],
rangeStartMs: number,
rangeEndMs: number,
onlyInterface?: string,
): BuiltTrafficSeries {
const empty: BuiltTrafficSeries = {
rxNow: 0,
txNow: 0,
rxPeak: 0,
txPeak: 0,
rxTotalGiB: 0,
txTotalGiB: 0,
sessions: 0,
rxSeries: Array.from({ length: SERIES_POINTS }, () => 0),
txSeries: Array.from({ length: SERIES_POINTS }, () => 0),
}
if (rows.length === 0) return empty
const byIface = new Map<string, TrafficSampleLike[]>()
for (const r of rows) {
const arr = byIface.get(r.interfaceName) ?? []
arr.push(r)
byIface.set(r.interfaceName, arr)
}
const rxPoints: Array<{ t: number; v: number }> = []
const txPoints: Array<{ t: number; v: number }> = []
const byTs = new Map<number, { rx: number; tx: number }>()
let rxBytesDelta = 0
let txBytesDelta = 0
let sessions = 0
for (const [name, arr] of byIface) {
if (onlyInterface) {
if (name !== onlyInterface) continue
} else if (isLoopbackName(name)) {
continue
}
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
const last = sorted[sorted.length - 1]
if (!last) continue
if (!onlyInterface && (!last.running || last.disabled)) continue
if (last.running && !last.disabled) sessions += 1
const first = sorted[0]
if (first) {
const dRx = last.rxBytes - first.rxBytes
const dTx = last.txBytes - first.txBytes
rxBytesDelta += dRx >= 0 ? dRx : last.rxBytes
txBytesDelta += dTx >= 0 ? dTx : last.txBytes
}
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1]
const cur = sorted[i]
if (!prev || !cur) continue
if (!onlyInterface && (!cur.running || cur.disabled)) continue
const t0 = parseIsoMs(prev.sampledAt)
const t1 = parseIsoMs(cur.sampledAt)
const rxBps = rateBpsFromDelta(prev.rxBytes, cur.rxBytes, t0, t1)
const txBps = rateBpsFromDelta(prev.txBytes, cur.txBytes, t0, t1)
if (rxBps == null && txBps == null) continue
const rxMbps = bpsToMbps(rxBps ?? 0)
const txMbps = bpsToMbps(txBps ?? 0)
const acc = byTs.get(t1) ?? { rx: 0, tx: 0 }
acc.rx += rxMbps
acc.tx += txMbps
byTs.set(t1, acc)
}
}
for (const [t, v] of byTs) {
rxPoints.push({ t, v: v.rx })
txPoints.push({ t, v: v.tx })
}
const rxSeries = bucketAvg(rxPoints, rangeStartMs, rangeEndMs)
const txSeries = bucketAvg(txPoints, rangeStartMs, rangeEndMs)
const lastTs = [...byTs.keys()].sort((a, b) => a - b).at(-1)
const last = lastTs != null ? byTs.get(lastTs) : undefined
const rxPeak = rxPoints.reduce((m, p) => Math.max(m, p.v), 0)
const txPeak = txPoints.reduce((m, p) => Math.max(m, p.v), 0)
return {
rxNow: last?.rx ?? 0,
txNow: last?.tx ?? 0,
rxPeak,
txPeak,
rxTotalGiB: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)),
txTotalGiB: Number((txBytesDelta / (1024 ** 3)).toFixed(1)),
sessions,
rxSeries,
txSeries,
}
}
export function parseMonitorTraffic(
raw: unknown,
opts?: { onlyInterface?: string },
): MonitorLiveSample {
const items = Array.isArray(raw) ? raw : raw != null ? [raw] : []
let rxBps = 0
let txBps = 0
for (const item of items) {
if (!item || typeof item !== "object") continue
const rec = item as Record<string, unknown>
const name = String(rec.name ?? rec.interface ?? "")
if (opts?.onlyInterface) {
if (name && name !== opts.onlyInterface) continue
} else if (isLoopbackName(name)) {
continue
}
rxBps += Number.parseFloat(String(rec["rx-bits-per-second"] ?? 0)) || 0
txBps += Number.parseFloat(String(rec["tx-bits-per-second"] ?? 0)) || 0
}
return {
rxMbps: bpsToMbps(rxBps),
txMbps: bpsToMbps(txBps),
at: new Date().toISOString(),
}
}
+16
View File
@@ -345,8 +345,24 @@ export interface RosFirewallFilter {
"dynamic"?: string "dynamic"?: string
"bytes"?: string "bytes"?: string
"packets"?: string "packets"?: string
"log"?: string
"log-prefix"?: string
} }
export interface RosFirewallAddressList {
".id": string
list: string
address: string
comment?: string
disabled?: string
timeout?: string
dynamic?: string
"creation-time"?: string
}
export type FirewallFamily = "ip" | "ip6"
export type FirewallTable = "filter" | "nat" | "mangle" | "raw"
export interface RosLogEntry { export interface RosLogEntry {
".id": string ".id": string
"time": string "time": string
@@ -8,7 +8,7 @@ import {
} from "@tanstack/react-table" } from "@tanstack/react-table"
import type { SchedulerJobStatusDto } from "@/lib/scheduler-settings" import type { SchedulerJobStatusDto } from "@/lib/scheduler-settings"
import { FormToggle } from "@/components/form-kit" import { FormToggle } from "@/components/form-kit"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/reui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
@@ -23,8 +23,11 @@ import {
DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST, DATA_GRID_CELL_PAD_LAST,
DATA_GRID_CONTAINER_CLASS,
} from "@/components/data-grids/shared/data-grid-layout" } from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { DataGridContainer, DataGridTableDndRowHandle, DataGridTableDndRows } from "@/components/reui/data-grid"
import type { DragEndEvent } from "@dnd-kit/core"
import { EmptyState } from "@/components/empty-state" import { EmptyState } from "@/components/empty-state"
import { import {
CopyIcon, CopyIcon,
@@ -89,16 +92,41 @@ interface FirewallRulesDataGridProps {
rules: FirewallRule[] rules: FirewallRule[]
onToggle: (id: string) => void onToggle: (id: string) => void
onEdit: (rule: FirewallRule) => void onEdit: (rule: FirewallRule) => void
onDelete?: (rule: FirewallRule) => void
onReorder?: (activeId: string, overId: string) => void
showServer?: boolean
} }
function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGridProps) { function FirewallRulesDataGrid({
rules,
onToggle,
onEdit,
onDelete,
onReorder,
showServer = false,
}: FirewallRulesDataGridProps) {
const indexedRules = useMemo( const indexedRules = useMemo(
() => rules.map((rule, index) => ({ ...rule, _index: index + 1 })), () => rules.map((rule, index) => ({ ...rule, _index: index + 1 })),
[rules], [rules],
) )
const reorderable = Boolean(onReorder)
const indexPad = reorderable ? DATA_GRID_CELL_PAD : DATA_GRID_CELL_PAD_FIRST
const columns = useMemo<ColumnDef<FirewallRule & { _index: number }>[]>( const columns = useMemo<ColumnDef<FirewallRule & { _index: number }>[]>(
() => [ () => [
...(reorderable
? [{
id: "drag",
header: () => <span className="sr-only">Порядок</span>,
enableSorting: false,
cell: () => <DataGridTableDndRowHandle />,
size: 40,
meta: {
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
} satisfies ColumnDef<FirewallRule & { _index: number }>]
: []),
{ {
id: "index", id: "index",
accessorKey: "_index", accessorKey: "_index",
@@ -114,10 +142,23 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
), ),
size: 48, size: 48,
meta: { meta: {
headerClassName: DATA_GRID_CELL_PAD_FIRST, headerClassName: indexPad,
cellClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: indexPad,
}, },
}, },
...(showServer
? [{
id: "server",
accessorFn: (row: FirewallRule & { _index: number }) => row.serverName ?? row.serverId ?? "",
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground truncate">
{row.original.serverName || row.original.serverId || "—"}
</span>
),
meta: { headerTitle: "Сервер", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
} satisfies ColumnDef<FirewallRule & { _index: number }>]
: []),
{ {
id: "chain", id: "chain",
accessorKey: "chain", accessorKey: "chain",
@@ -261,7 +302,7 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
{r.enabled ? "Отключить" : "Включить"} {r.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"> <DropdownMenuItem variant="destructive" onClick={() => onDelete?.(r)}>
<Trash2Icon className="size-4" /> <Trash2Icon className="size-4" />
Удалить правило Удалить правило
</DropdownMenuItem> </DropdownMenuItem>
@@ -274,17 +315,24 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST }, meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
}, },
], ],
[onEdit, onToggle], [onEdit, onToggle, onDelete, showServer, reorderable, indexPad],
) )
const table = useReactTable({ const table = useReactTable({
data: indexedRules, data: indexedRules,
columns, columns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(), ...(reorderable ? {} : { getSortedRowModel: getSortedRowModel() }),
enableSorting: !reorderable,
getRowId: (row) => row.id, getRowId: (row) => row.id,
}) })
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id || !onReorder) return
onReorder(String(active.id), String(over.id))
}
if (rules.length === 0) { if (rules.length === 0) {
return ( return (
<EmptyState <EmptyState
@@ -304,7 +352,16 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
headerRow: "border-b border-border", headerRow: "border-b border-border",
bodyRow: cn("group/row", "[&:has([data-rule-disabled=true])]:opacity-40"), bodyRow: cn("group/row", "[&:has([data-rule-disabled=true])]:opacity-40"),
}} }}
/> >
{reorderable ? (
<DataGridContainer border={false} className={DATA_GRID_CONTAINER_CLASS}>
<DataGridTableDndRows
dataIds={indexedRules.map((r) => r.id)}
handleDragEnd={handleDragEnd}
/>
</DataGridContainer>
) : undefined}
</DataGridShell>
) )
} }
+3 -3
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/reui/badge"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { import {
CompactDataGrid, CompactDataGrid,
@@ -35,11 +35,11 @@ function SnapshotOkBadge({
errLabel?: string errLabel?: string
}) { }) {
return ok ? ( return ok ? (
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400"> <Badge variant="success-outline" size="sm" className="text-[10px]">
{okLabel} {okLabel}
</Badge> </Badge>
) : ( ) : (
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive"> <Badge variant="destructive-outline" size="sm" className="text-[10px]">
{errLabel} {errLabel}
</Badge> </Badge>
) )
+28 -19
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useMemo } from "react" import { useMemo, type ReactNode } from "react"
import { import {
type ColumnDef, type ColumnDef,
getCoreRowModel, getCoreRowModel,
@@ -11,6 +11,7 @@ import {
import type { WireGuardInterface } from "@/lib/data" import type { WireGuardInterface } from "@/lib/data"
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Badge } from "@/components/reui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
DropdownMenu, DropdownMenu,
@@ -47,6 +48,8 @@ export interface WgIfaceWithServer extends WireGuardInterface {
interface WireguardDataGridProps { interface WireguardDataGridProps {
interfaces: WgIfaceWithServer[] interfaces: WgIfaceWithServer[]
compactServer?: boolean
emptyAction?: ReactNode
onExport: (iface: WgIfaceWithServer) => void onExport: (iface: WgIfaceWithServer) => void
onAddPeer?: (iface: WgIfaceWithServer) => void onAddPeer?: (iface: WgIfaceWithServer) => void
onToggle?: (iface: WgIfaceWithServer) => void onToggle?: (iface: WgIfaceWithServer) => void
@@ -57,6 +60,8 @@ interface WireguardDataGridProps {
function WireguardDataGrid({ function WireguardDataGrid({
interfaces, interfaces,
compactServer = false,
emptyAction,
onExport, onExport,
onAddPeer, onAddPeer,
onToggle, onToggle,
@@ -70,7 +75,11 @@ function WireguardDataGrid({
id: "name", id: "name",
accessorKey: "name", accessorKey: "name",
header: ({ column }) => ( header: ({ column }) => (
<DataGridSortHeader column={column} title="Интерфейс / Сервер" className="ml-1" /> <DataGridSortHeader
column={column}
title={compactServer ? "Интерфейс" : "Интерфейс / Сервер"}
className="ml-1"
/>
), ),
cell: ({ row }) => { cell: ({ row }) => {
const iface = row.original const iface = row.original
@@ -88,15 +97,17 @@ function WireguardDataGrid({
<span <span
className={cn( className={cn(
"size-2 rounded-full shrink-0", "size-2 rounded-full shrink-0",
iface.status === "up" ? "bg-emerald-500 animate-pulse" : "bg-red-500", iface.status === "up" ? "bg-success animate-pulse" : "bg-destructive",
)} )}
/> />
<span className="font-mono font-semibold text-sm">{iface.name}</span> <span className="font-mono font-semibold text-sm">{iface.name}</span>
</div> </div>
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono"> {!compactServer ? (
<Flag code={iface.serverCountry || "UN"} size={12} /> <div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
{iface.serverName} <Flag code={iface.serverCountry || "UN"} size={12} />
</div> {iface.serverName}
</div>
) : null}
<p className="sr-only"> <p className="sr-only">
{onlinePeers}/{iface.peers.length} пиров {onlinePeers}/{iface.peers.length} пиров
</p> </p>
@@ -105,7 +116,7 @@ function WireguardDataGrid({
) )
}, },
meta: { meta: {
headerTitle: "Интерфейс / Сервер", headerTitle: compactServer ? "Интерфейс" : "Интерфейс / Сервер",
headerClassName: DATA_GRID_CELL_PAD_FIRST, headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (row: WgIfaceWithServer) => ( expandedContent: (row: WgIfaceWithServer) => (
@@ -152,7 +163,7 @@ function WireguardDataGrid({
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
return ( return (
<span className="font-mono text-sm text-center block"> <span className="font-mono text-sm text-center block">
<span className="text-emerald-600 dark:text-emerald-400">{onlinePeers}</span> <span className="text-success">{onlinePeers}</span>
<span className="text-muted-foreground">/{iface.peers.length}</span> <span className="text-muted-foreground">/{iface.peers.length}</span>
</span> </span>
) )
@@ -168,16 +179,13 @@ function WireguardDataGrid({
accessorKey: "status", accessorKey: "status",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />, header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span <Badge
className={cn( variant={row.original.status === "up" ? "success-light" : "destructive-light"}
"text-[11px] font-mono px-2 py-0.5 rounded border", size="sm"
row.original.status === "up" className="font-mono"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-red-500/10 text-red-500 border-red-500/20",
)}
> >
{row.original.status === "up" ? "UP" : "DOWN"} {row.original.status === "up" ? "UP" : "DOWN"}
</span> </Badge>
), ),
meta: { meta: {
headerTitle: "Статус", headerTitle: "Статус",
@@ -258,7 +266,7 @@ function WireguardDataGrid({
}, },
}, },
], ],
[onExport, onAddPeer, onToggle, onDelete, onDeletePeer, onExportPeer], [compactServer, onExport, onAddPeer, onToggle, onDelete, onDeletePeer, onExportPeer],
) )
const table = useReactTable({ const table = useReactTable({
@@ -276,7 +284,8 @@ function WireguardDataGrid({
<EmptyState <EmptyState
icon={<ShieldCheckIcon className="size-4" />} icon={<ShieldCheckIcon className="size-4" />}
title="Нет WireGuard интерфейсов" title="Нет WireGuard интерфейсов"
description="Добавьте первый интерфейс или проверьте поиск" description="Добавьте первый интерфейс или сбросьте фильтры"
action={emptyAction}
className="border-0 py-16" className="border-0 py-16"
/> />
) )
@@ -74,18 +74,18 @@ function WireGuardPeersDetail({
<span <span
className={cn( className={cn(
"font-mono text-[11px] whitespace-nowrap", "font-mono text-[11px] whitespace-nowrap",
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground", peer.latestHandshake ? "text-success" : "text-muted-foreground",
)} )}
> >
{peer.latestHandshake ?? "нет рукопожатия"} {peer.latestHandshake ?? "нет рукопожатия"}
</span> </span>
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap"> <div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
<span className="flex items-center gap-0.5"> <span className="flex items-center gap-0.5">
<ArrowDownIcon className="size-3 text-emerald-500" /> <ArrowDownIcon className="size-3 text-success" />
{fmtBytes(peer.transferRx)} {fmtBytes(peer.transferRx)}
</span> </span>
<span className="flex items-center gap-0.5"> <span className="flex items-center gap-0.5">
<ArrowUpIcon className="size-3 text-blue-400" /> <ArrowUpIcon className="size-3 text-info" />
{fmtBytes(peer.transferTx)} {fmtBytes(peer.transferTx)}
</span> </span>
</div> </div>
@@ -0,0 +1,268 @@
"use client"
import { useMemo, type ReactNode } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { WireGuardPeer } from "@/lib/data"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { fmtBytes, truncKey } from "@/components/data-grids/wireguard-peers-detail"
import {
ArrowDownIcon,
ArrowUpIcon,
CodeXmlIcon,
KeyRoundIcon,
Trash2Icon,
UsersIcon,
} from "lucide-react"
export interface WgPeerRow extends WireGuardPeer {
id: string
ifaceId: string
ifaceName: string
serverId: string
serverName: string
serverCountry: string
}
interface WireguardPeersGridProps {
peers: WgPeerRow[]
compactServer?: boolean
emptyAction?: ReactNode
onDeletePeer?: (row: WgPeerRow) => void
onExportPeer?: (row: WgPeerRow) => void
}
function WireguardPeersGrid({
peers,
compactServer = false,
emptyAction,
onDeletePeer,
onExportPeer,
}: WireguardPeersGridProps) {
const columns = useMemo<ColumnDef<WgPeerRow>[]>(() => {
const cols: ColumnDef<WgPeerRow>[] = [
{
id: "peer",
accessorKey: "publicKey",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Пир" className="ml-1" />
),
cell: ({ row }) => {
const peer = row.original
return (
<div className="flex min-w-0 items-center gap-1.5">
<KeyRoundIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate font-mono text-xs" title={peer.publicKey}>
{peer.name || truncKey(peer.publicKey)}
</span>
</div>
)
},
meta: {
headerTitle: "Пир",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "iface",
accessorKey: "ifaceName",
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
cell: ({ row }) => (
<span className="font-mono text-sm">{row.original.ifaceName}</span>
),
meta: {
headerTitle: "Интерфейс",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
]
if (!compactServer) {
cols.push({
id: "server",
accessorKey: "serverName",
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
cell: ({ row }) => (
<div className="flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground">
<Flag code={row.original.serverCountry || "UN"} size={12} />
{row.original.serverName}
</div>
),
meta: {
headerTitle: "Сервер",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
})
}
cols.push(
{
id: "allowedIps",
accessorFn: (row) => row.allowedIps.join(", "),
header: ({ column }) => <DataGridSortHeader column={column} title="Allowed IPs" />,
cell: ({ row }) => (
<span className="block truncate font-mono text-xs text-muted-foreground">
{row.original.allowedIps.join(", ") || "—"}
</span>
),
meta: {
headerTitle: "Allowed IPs",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "handshake",
accessorKey: "latestHandshake",
header: ({ column }) => <DataGridSortHeader column={column} title="Handshake" />,
cell: ({ row }) => (
<span
className={cn(
"whitespace-nowrap font-mono text-[11px]",
row.original.latestHandshake ? "text-success" : "text-muted-foreground",
)}
>
{row.original.latestHandshake ?? "нет рукопожатия"}
</span>
),
meta: {
headerTitle: "Handshake",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "transfer",
header: () => (
<span className="text-xs font-medium text-muted-foreground">RX / TX</span>
),
enableSorting: false,
cell: ({ row }) => (
<div className="flex items-center gap-2 whitespace-nowrap text-xs text-muted-foreground">
<span className="flex items-center gap-0.5">
<ArrowDownIcon className="size-3 text-success" />
{fmtBytes(row.original.transferRx)}
</span>
<span className="flex items-center gap-0.5">
<ArrowUpIcon className="size-3 text-info" />
{fmtBytes(row.original.transferTx)}
</span>
</div>
),
meta: {
headerTitle: "RX / TX",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "endpoint",
accessorKey: "endpoint",
header: ({ column }) => <DataGridSortHeader column={column} title="Endpoint" />,
cell: ({ row }) => (
<span className="font-mono text-[11px] text-muted-foreground">
{row.original.endpoint ?? "—"}
</span>
),
meta: {
headerTitle: "Endpoint",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
enableSorting: false,
size: 72,
cell: ({ row }) => {
const peer = row.original
return (
<div className="flex justify-end gap-0.5">
{onExportPeer ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
aria-label="Экспорт peer .conf"
onClick={() => onExportPeer(peer)}
>
<CodeXmlIcon className="size-3.5" />
</Button>
) : null}
{onDeletePeer ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-destructive"
aria-label="Удалить пира"
onClick={() => onDeletePeer(peer)}
>
<Trash2Icon className="size-3.5" />
</Button>
) : null}
</div>
)
},
meta: {
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
)
return cols
}, [compactServer, onDeletePeer, onExportPeer])
const table = useReactTable({
data: peers,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (peers.length === 0) {
return (
<EmptyState
icon={<UsersIcon className="size-4" />}
title="Нет пиров"
description="Добавьте пира к интерфейсу или сбросьте поиск"
action={emptyAction}
className="border-0 py-16"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={peers.length}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: cn("group/row"),
}}
/>
)
}
export { WireguardPeersGrid, type WireguardPeersGridProps }
+3
View File
@@ -31,6 +31,7 @@ function DataPageToolbarFrame({
} }
interface DataPageToolbarProps<T extends string = string> { interface DataPageToolbarProps<T extends string = string> {
leading?: ReactNode
search?: string search?: string
onSearchChange?: (value: string) => void onSearchChange?: (value: string) => void
searchPlaceholder?: string searchPlaceholder?: string
@@ -48,6 +49,7 @@ interface DataPageToolbarProps<T extends string = string> {
} }
function DataPageToolbar<T extends string = string>({ function DataPageToolbar<T extends string = string>({
leading,
search, search,
onSearchChange, onSearchChange,
searchPlaceholder = "Поиск…", searchPlaceholder = "Поиск…",
@@ -61,6 +63,7 @@ function DataPageToolbar<T extends string = string>({
}: DataPageToolbarProps<T>) { }: DataPageToolbarProps<T>) {
return ( return (
<DataPageToolbarFrame className={className}> <DataPageToolbarFrame className={className}>
{leading}
{segmented && ( {segmented && (
<SegmentedControl <SegmentedControl
value={segmented.value} value={segmented.value}
+1 -1
View File
@@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import Link from "next/link" import Link from "next/link"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/reui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
+284
View File
@@ -0,0 +1,284 @@
"use client"
import { useEffect, useMemo, useState, type ReactNode } from "react"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import { Frame, FramePanel } from "@/components/reui/frame"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
CheckIcon,
CopyIcon,
DownloadIcon,
LoaderCircleIcon,
RefreshCwIcon,
TriangleAlertIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
export type CodeExportFormat = {
id: string
label: string
filename: string
code: string
/** Empty / unavailable — show warning instead of code */
emptyMessage?: string
/** Content came from live device fetch */
isLive?: boolean
}
function downloadText(filename: string, content: string) {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
function highlightLine(line: string): string {
const trimmed = line.trimStart()
if (line.startsWith("#") || trimmed.startsWith(";")) return "text-muted-foreground"
if (trimmed.startsWith("[")) return "text-info"
if (trimmed.startsWith("/")) return "text-info"
if (/^\s+[a-zA-Z]/.test(line) || /^[A-Za-z][\w-]*=/.test(line)) return "text-foreground"
return "text-foreground/90"
}
function CodeBlock({ code }: { code: string }) {
const lines = code.length ? code.split("\n") : [""]
return (
<pre className="px-4 py-3.5 text-[12px] font-mono leading-[1.65] whitespace-pre-wrap break-all select-all">
{lines.map((line, i) => (
<span key={i} className={cn("block", highlightLine(line))}>
{line || " "}
</span>
))}
</pre>
)
}
export interface CodeExportSheetProps {
open: boolean
onClose: () => void
title: string
description?: ReactNode
formats: CodeExportFormat[]
/** Initial format id when sheet opens */
initialFormatId?: string
/** Optional live fetch from device */
onLiveFetch?: (formatId: string) => void
liveBusy?: boolean
/** Short CTA — keep laconic */
liveLabel?: string
className?: string
/** Extra content under format tabs (e.g. meta strip) */
beforeCode?: ReactNode
}
/**
* Shared code export Sheet — ReUI Frame + sticky footer.
* Preview DNA: https://reui.io/preview/base/sheet-8 · Frame https://reui.io/docs/components/base/frame
* Alert: https://reui.io/docs/components/base/alert
*/
function CodeExportSheet({
open,
onClose,
title,
description,
formats,
initialFormatId,
onLiveFetch,
liveBusy,
liveLabel = "С роутера",
className,
beforeCode,
}: CodeExportSheetProps) {
const firstId = formats[0]?.id ?? "default"
const [tab, setTab] = useState(initialFormatId ?? firstId)
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!open) return
setTab(
initialFormatId && formats.some((f) => f.id === initialFormatId)
? initialFormatId
: firstId,
)
setCopied(false)
}, [open, initialFormatId, firstId, formats])
const active = useMemo(
() => formats.find((f) => f.id === tab) ?? formats[0],
[formats, tab],
)
const code = active?.code ?? ""
const emptyMessage = active?.emptyMessage
const filename = active?.filename ?? "export.txt"
const canCopy = Boolean(code) && !emptyMessage
const isLive = Boolean(active?.isLive)
const showTabs = formats.length > 1
function handleCopy() {
if (!canCopy) return
void navigator.clipboard.writeText(code).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1800)
})
}
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent
className={cn(
"flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-xl",
className,
)}
>
<SheetHeader className="shrink-0 gap-1 border-b px-5 pt-5 pb-4 pr-12">
<SheetTitle className="text-base font-semibold tracking-tight">
{title}
</SheetTitle>
{description ? (
<SheetDescription className="font-mono text-xs">
{description}
</SheetDescription>
) : null}
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-3 px-5 py-4">
{showTabs ? (
<Tabs
value={tab}
onValueChange={(v) => setTab(String(v))}
className="shrink-0 gap-0"
>
<TabsList className="h-9 w-full">
{formats.map((f) => (
<TabsTrigger key={f.id} value={f.id} className="flex-1 px-2 text-xs sm:text-sm">
{f.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
{onLiveFetch || beforeCode ? (
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{onLiveFetch ? (
<Badge
variant={isLive ? "success-light" : "secondary"}
size="sm"
radius="full"
>
{isLive ? "С роутера" : "Локально"}
</Badge>
) : null}
{beforeCode}
</div>
{onLiveFetch ? (
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
disabled={liveBusy}
onClick={() => onLiveFetch(tab)}
aria-label="Подтянуть конфиг с роутера с private-key"
>
{liveBusy ? (
<LoaderCircleIcon className="size-3.5 animate-spin" />
) : (
<RefreshCwIcon className="size-3.5" />
)}
{liveBusy ? "Загрузка…" : liveLabel}
</Button>
) : null}
</div>
) : null}
<Frame dense className="flex min-h-0 flex-1 flex-col">
<FramePanel className="relative flex min-h-0 flex-1 flex-col overflow-hidden p-0">
{emptyMessage ? (
<div className="flex flex-1 items-center p-4">
<Alert variant="warning" className="w-full">
<TriangleAlertIcon />
<AlertTitle>Нет данных</AlertTitle>
<AlertDescription>{emptyMessage}</AlertDescription>
</Alert>
</div>
) : (
<ScrollArea className="h-full min-h-0 flex-1">
<div className="min-h-[min(52vh,22rem)]">
<CodeBlock code={code} />
</div>
</ScrollArea>
)}
</FramePanel>
</Frame>
{onLiveFetch && !isLive ? (
<p className="text-muted-foreground shrink-0 text-[11px] leading-snug">
Локальный снимок. Подтяните с роутера, если нужен private-key с устройства.
</p>
) : null}
</div>
<SheetFooter className="shrink-0 flex-row items-center justify-between gap-3 border-t px-5 py-3.5 sm:flex-row">
<SheetClose
render={
<Button type="button" variant="ghost" className="shrink-0 px-3" />
}
>
Закрыть
</SheetClose>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="default"
disabled={!canCopy}
onClick={() => downloadText(filename, code)}
>
<DownloadIcon className="size-4" />
Файл
</Button>
<Button
type="button"
size="default"
disabled={!canCopy}
onClick={handleCopy}
className="min-w-28"
>
{copied ? (
<CheckIcon className="size-4 text-success" />
) : (
<CopyIcon className="size-4" />
)}
{copied ? "Скопировано" : "Копировать"}
</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { CodeExportSheet, downloadText }
+5
View File
@@ -0,0 +1,5 @@
export { CodeExportSheet, downloadText } from "./code-export-sheet"
export type { CodeExportFormat, CodeExportSheetProps } from "./code-export-sheet"
export { KpiStatGrid, KpiStatCardTile, kpiStatItemKey } from "./kpi-stat-grid"
export type { KpiStatItem, KpiStatCardData, KpiStatVariant } from "./kpi-stat-grid"
export { kpiCols } from "./kpi-cols"
+12
View File
@@ -0,0 +1,12 @@
/**
* Shared grid column classes for hybrid KPI tiles.
* @see https://reui.io/preview/base/stats-12
*/
export function kpiCols(count: number): string {
if (count <= 1) return "grid-cols-1"
if (count === 2) return "grid-cols-1 @xl:grid-cols-2"
if (count === 3) return "grid-cols-1 @3xl:grid-cols-3"
if (count === 4) return "grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4"
if (count <= 6) return "grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3"
return "grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 @6xl:grid-cols-4"
}
+307
View File
@@ -0,0 +1,307 @@
"use client"
import type { KeyboardEvent, ReactNode } from "react"
import Link from "next/link"
import { Frame, FramePanel } from "@/components/reui/frame"
import { Badge } from "@/components/reui/badge"
import { IconTile } from "@/components/reui/icon-tile"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
import { kpiCols } from "./kpi-cols"
export type KpiStatVariant = "default" | "warning" | "destructive"
/**
* KPI tile — horizontal compact hybrid (icon left + label/Badge + value).
* @see https://reui.io/preview/base/stats-12
*/
export type KpiStatItem = {
id?: string
label: ReactNode
value: ReactNode
hint?: ReactNode
href?: string
onSelect?: () => void
onClick?: () => void
selected?: boolean
active?: boolean
icon?: ReactNode
iconClassName?: string
variant?: KpiStatVariant
footer?: ReactNode
}
export type KpiStatCardData = KpiStatItem & { id: string }
const DEFAULT_ICON_CLASS = "text-muted-foreground [&_svg]:text-current"
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
default: "text-foreground",
warning: "text-warning",
destructive: "text-destructive",
}
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
onActivate()
}
}
function resolveActivate(item: KpiStatItem): (() => void) | undefined {
return item.onClick ?? item.onSelect
}
function isSelected(item: KpiStatItem): boolean {
return Boolean(item.selected ?? item.active)
}
function resolveFooter(item: KpiStatItem): ReactNode {
if (item.footer) return item.footer
if (typeof item.hint === "string") {
return (
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
{item.hint}
</Badge>
)
}
if (item.hint) return item.hint
return null
}
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const footer = resolveFooter(item)
const valueVariant = item.variant ?? "default"
return (
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
{item.icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className={cn("size-10.5 shrink-0", item.iconClassName ?? DEFAULT_ICON_CLASS)}
>
{item.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
{item.label}
</div>
{footer ? (
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
{footer}
</div>
) : null}
</div>
<div
className={cn(
"min-w-0 break-all text-2xl leading-none font-bold tabular-nums",
VALUE_VARIANT_CLASS[valueVariant],
)}
>
{item.value}
</div>
{footer ? (
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
) : null}
</div>
</div>
)
}
function panelClassName(item: KpiStatItem, className?: string) {
const onActivate = resolveActivate(item)
const clickable = Boolean(item.href || onActivate)
const selected = isSelected(item)
return cn(
"relative isolate flex h-full min-w-0 flex-col",
clickable &&
"hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2",
selected && "ring-primary/30 bg-muted/30 ring-1",
className,
)
}
export function KpiStatCardTile({
item,
embedded = false,
className,
}: {
item: KpiStatItem
embedded?: boolean
className?: string
}) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item, className)
let panel: ReactNode
if (item.href) {
panel = (
<FramePanel className={panelClass}>
<Link href={item.href} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
} else if (onActivate) {
panel = (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
} else {
panel = (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
if (embedded) {
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
}
return <Frame className="h-full">{panel}</Frame>
}
function KpiStatGridSkeleton({ count }: { count: number }) {
return (
<Frame className="@container w-full">
<div className={cn("grid gap-2", kpiCols(count))}>
{Array.from({ length: count }).map((_, index) => (
<FramePanel key={index} className="flex items-start gap-3">
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4.5 w-14 rounded-full" />
</div>
<Skeleton className="h-7 w-16" />
</div>
</FramePanel>
))}
</div>
</Frame>
)
}
interface KpiStatGridProps {
items?: KpiStatItem[]
cards?: KpiStatCardData[]
isLoading?: boolean
emptyMessage?: ReactNode
emptyIcon?: ReactNode
className?: string
skeletonCount?: number
embedded?: boolean
"aria-label"?: string
}
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item)
if (item.href) {
return (
<FramePanel className={panelClass}>
<Link href={item.href} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
}
if (onActivate) {
return (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
return (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
/**
* Hybrid KPI — horizontal compact layout (icon left).
* Preview: https://reui.io/preview/base/stats-12
*/
export function KpiStatGrid({
items,
cards,
isLoading = false,
emptyMessage,
emptyIcon,
className,
skeletonCount = 4,
embedded = false,
"aria-label": ariaLabel,
}: KpiStatGridProps) {
if (isLoading) {
return <KpiStatGridSkeleton count={skeletonCount} />
}
const list = items ?? cards ?? []
if (list.length === 0 && (emptyMessage || emptyIcon)) {
return (
<Frame dense spacing="sm" className={cn("w-full", className)}>
<FramePanel className="flex items-center gap-3 p-4">
{emptyIcon}
{emptyMessage ? (
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
) : null}
</FramePanel>
</Frame>
)
}
if (embedded) {
return (
<section aria-label={ariaLabel} className={cn("@container w-full", className)}>
<div className={cn("grid gap-2", kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
))}
</div>
</section>
)
}
return (
<Frame className={cn("@container w-full min-w-0", className)} aria-label={ariaLabel}>
<div className={cn("grid gap-2", kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
))}
</div>
</Frame>
)
}
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
if (item.id) return item.id
if (typeof item.label === "string") return item.label
return `kpi-${index}`
}
+132
View File
@@ -0,0 +1,132 @@
"use client"
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import { Frame, FramePanel } from "@/components/reui/frame"
import { ChartContainer, ChartTooltip, type ChartConfig } from "@/components/ui/chart"
import { fmtRate, formatRangeAgo, TRAFFIC_RANGE_MINUTES } from "@/lib/fmt-rate"
/**
* История RX/TX — adapt ReUI PRO chart-23 (multi-line + tooltip).
* @see https://reui.io/preview/base/chart-23
* @see https://reui.io/blocks
*/
const chartConfig = {
rx: { label: "RX", color: "var(--chart-rx)" },
tx: { label: "TX", color: "var(--chart-tx)" },
} satisfies ChartConfig
function ChartLegendItem({ label, color }: { label: string; color: string }) {
return (
<div className="flex items-center gap-2">
<div className="size-2 rounded-full" style={{ backgroundColor: color }} />
<span className="text-muted-foreground text-xs font-medium">{label}</span>
</div>
)
}
function CustomTooltip({
active,
payload,
}: {
active?: boolean
payload?: {
dataKey: string
color: string
value: number
}[]
}) {
if (!active || !payload?.length) return null
return (
<div className="min-w-[120px] flex flex-col gap-1.5 rounded-lg bg-popover p-3 text-popover-foreground shadow-lg ring-1 ring-foreground/10">
{payload.map((item) => (
<div key={item.dataKey}>
<div className="text-[10px] font-medium tracking-wider uppercase opacity-70">
{item.dataKey === "rx" ? "RX" : "TX"}:
</div>
<div className="text-sm font-semibold tabular-nums">
{fmtRate(item.value)}
</div>
</div>
))}
</div>
)
}
function toChartData(rx: number[], tx: number[], rangeMinutes: number) {
const n = Math.max(rx.length, tx.length, 1)
const denom = Math.max(n - 1, 1)
return Array.from({ length: n }, (_, i) => {
const minutesAgo = Math.round((1 - i / denom) * rangeMinutes)
return {
time: formatRangeAgo(minutesAgo),
rx: rx[i] ?? 0,
tx: tx[i] ?? 0,
}
})
}
export function TrafficRxTxChart({
rx,
tx,
range = "1h",
}: {
rx: number[]
tx: number[]
range?: string
}) {
const rangeMinutes = TRAFFIC_RANGE_MINUTES[range] ?? 60
const data = toChartData(rx, tx, rangeMinutes)
const tickEvery = Math.max(1, Math.ceil(data.length / 6))
return (
<Frame className="w-full">
<FramePanel className="flex flex-col gap-6">
<ChartContainer config={chartConfig} className="-ms-4 aspect-auto h-[220px] w-full">
<LineChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
<CartesianGrid
strokeDasharray="4 8"
vertical={false}
stroke="var(--border)"
/>
<XAxis
dataKey="time"
axisLine={false}
tickLine={false}
tick={{ fontSize: 11 }}
tickMargin={10}
interval={tickEvery - 1}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 11 }}
tickFormatter={(v: number) => fmtRate(Number(v))}
tickMargin={8}
width={72}
/>
<ChartTooltip content={<CustomTooltip />} />
<Line
dataKey="rx"
type="monotone"
stroke="var(--chart-rx)"
strokeWidth={2}
dot={false}
/>
<Line
dataKey="tx"
type="monotone"
stroke="var(--chart-tx)"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ChartContainer>
<div className="mb-1 flex items-center justify-center gap-6">
<ChartLegendItem label="RX (входящий)" color="var(--chart-rx)" />
<ChartLegendItem label="TX (исходящий)" color="var(--chart-tx)" />
</div>
</FramePanel>
</Frame>
)
}
+236
View File
@@ -0,0 +1,236 @@
"use client"
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react"
import { PanelLeftCloseIcon, ServerIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import {
ALL_SERVERS_ID,
ServerTileRail,
hostnameOf,
type ServerTileItem,
} from "@/components/server-tile-rail"
import { cn } from "@/lib/utils"
/** Preview: https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/list-9 */
const STORAGE_KEY = "mm:server-rail-collapsed"
const DESKTOP_MQ = "(min-width: 768px)"
function readCollapsed(): boolean {
try {
return window.localStorage.getItem(STORAGE_KEY) === "1"
} catch {
return false
}
}
function writeCollapsed(value: boolean) {
try {
window.localStorage.setItem(STORAGE_KEY, value ? "1" : "0")
} catch {
// quota / private mode
}
}
interface ServerRailContextValue {
openRail: () => void
expandRail: () => void
collapsed: boolean
selectedLabel: string
}
const ServerRailContext = createContext<ServerRailContextValue | null>(null)
function useServerRail() {
const ctx = useContext(ServerRailContext)
if (!ctx) {
throw new Error("ServerRailMobileButton must be used inside ServerRailLayout")
}
return ctx
}
function ServerRailMobileButton() {
const { openRail, expandRail, collapsed, selectedLabel } = useServerRail()
return (
<Button
size="sm"
variant="outline"
className={collapsed ? undefined : "md:hidden"}
aria-label={collapsed ? "Показать список серверов" : "Серверы"}
onClick={() => {
if (window.matchMedia(DESKTOP_MQ).matches) {
expandRail()
return
}
openRail()
}}
>
<ServerIcon data-icon="inline-start" />
{selectedLabel}
</Button>
)
}
function ServerRailLayout({
items,
selectedId,
onSelect,
showAll = true,
allCount = 0,
showCount = true,
showType = true,
headerRight,
loading = false,
extra,
header,
banner,
contentClassName,
children,
}: {
items: ServerTileItem[]
selectedId: string
onSelect: (id: string) => void
showAll?: boolean
allCount?: number
showCount?: boolean
showType?: boolean
headerRight?: ReactNode
loading?: boolean
extra?: ReactNode
header?: ReactNode
banner?: ReactNode
contentClassName?: string
children: ReactNode
}) {
const [railOpen, setRailOpen] = useState(false)
const [collapsed, setCollapsed] = useState(false)
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
setCollapsed(readCollapsed())
setHydrated(true)
}, [])
useEffect(() => {
if (!hydrated) return
writeCollapsed(collapsed)
}, [collapsed, hydrated])
const handleSelect = useCallback(
(id: string) => {
onSelect(id)
setRailOpen(false)
},
[onSelect],
)
const expandRail = useCallback(() => {
setCollapsed(false)
}, [])
const selectedLabel = useMemo(() => {
if (showAll && selectedId === ALL_SERVERS_ID) return "Все серверы"
const item = items.find((entry) => entry.id === selectedId)
return item ? hostnameOf(item) : "Сервер"
}, [items, selectedId, showAll])
const ctx = useMemo<ServerRailContextValue>(
() => ({
openRail: () => setRailOpen(true),
expandRail,
collapsed,
selectedLabel,
}),
[collapsed, expandRail, selectedLabel],
)
const railHeaderRight = (
<>
{headerRight}
<Button
type="button"
variant="ghost"
size="icon-sm"
className="hidden md:inline-flex"
aria-label="Свернуть список серверов"
onClick={() => setCollapsed(true)}
>
<PanelLeftCloseIcon />
</Button>
</>
)
const rail = (
<ServerTileRail
items={items}
selectedId={selectedId}
onSelect={handleSelect}
showAll={showAll}
allCount={allCount}
showCount={showCount}
showType={showType}
headerRight={railHeaderRight}
loading={loading}
className="min-h-0 flex-1"
/>
)
return (
<ServerRailContext.Provider value={ctx}>
<div className="flex h-full min-h-0 flex-col">
{header}
{banner}
<div className="flex min-h-0 flex-1">
{!collapsed ? (
<aside className="hidden min-h-0 w-60 shrink-0 flex-col gap-3 p-3 pr-0 md:flex">
{rail}
{extra}
</aside>
) : null}
<div className={cn("min-w-0 flex-1 overflow-y-auto p-4 md:p-6", contentClassName)}>
{children}
</div>
</div>
<Sheet open={railOpen} onOpenChange={setRailOpen}>
<SheetContent side="left" className="flex w-72 flex-col gap-3 p-3" showCloseButton>
<SheetHeader className="px-1 pt-1">
<SheetTitle>Серверы</SheetTitle>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-3">
<ServerTileRail
items={items}
selectedId={selectedId}
onSelect={handleSelect}
showAll={showAll}
allCount={allCount}
showCount={showCount}
showType={showType}
showHeader={false}
headerRight={headerRight}
loading={loading}
className="min-h-0 flex-1"
/>
{extra}
</div>
</SheetContent>
</Sheet>
</div>
</ServerRailContext.Provider>
)
}
export { ServerRailLayout, ServerRailMobileButton }
+385
View File
@@ -0,0 +1,385 @@
"use client"
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"
import type { ServerStatus, ServerType } from "@/lib/data"
import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
import { Badge } from "@/components/reui/badge"
import { IconTile } from "@/components/reui/icon-tile"
import {
Frame,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import {
Item,
ItemActions,
ItemContent,
ItemMedia,
ItemTitle,
} from "@/components/ui/item"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Spinner } from "@/components/ui/spinner"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import { cn } from "@/lib/utils"
import { LayersIcon, SearchIcon, ServerIcon } from "lucide-react"
/** Preview: https://reui.io/preview/base/list-9 · https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/badge */
export const ALL_SERVERS_ID = "all"
export interface ServerTileItem {
id: string
name: string
country?: string
status?: ServerStatus
type?: ServerType
count?: number
enabled?: boolean
selectable?: boolean
title?: string
site?: string
host?: string
meta?: string
}
function typeBadgeVariant(
type: ServerType,
): "focus-light" | "info-light" | "success-light" {
if (type === "jump-host") return "focus-light"
if (type === "home-router") return "success-light"
return "info-light"
}
function typeBadgeLabel(type: ServerType): string {
if (type === "jump-host") return "JH"
if (type === "home-router") return "HR"
return "EN"
}
function ServerTypeBadge({ type }: { type: ServerType }) {
return (
<Badge variant={typeBadgeVariant(type)} size="xs" className="font-mono">
{typeBadgeLabel(type)}
</Badge>
)
}
function hostnameOf(item: ServerTileItem): string {
return item.name || item.host || ""
}
function matchesQuery(item: ServerTileItem, q: string): boolean {
if (!q) return true
const hay = [item.name, item.title ?? "", item.host ?? "", item.site ?? "", item.country ?? ""]
.join(" ")
.toLowerCase()
return hay.includes(q)
}
function isSelectable(item: ServerTileItem): boolean {
return item.selectable !== false
}
function ServerTileRail({
items,
selectedId,
onSelect,
allCount = 0,
showHeader = true,
showAll = true,
showCount = true,
showType = true,
headerRight,
loading = false,
className,
}: {
items: ServerTileItem[]
selectedId: string
onSelect: (id: string) => void
allCount?: number
showHeader?: boolean
showAll?: boolean
showCount?: boolean
showType?: boolean
headerRight?: ReactNode
loading?: boolean
className?: string
}) {
const [query, setQuery] = useState("")
const q = query.trim().toLowerCase()
const listRef = useRef<HTMLDivElement>(null)
const filtered = useMemo(
() => items.filter((item) => matchesQuery(item, q)),
[items, q],
)
const uniqueSites = useMemo(() => {
const sites = new Set(
filtered.map((item) => item.site?.trim()).filter((site): site is string => Boolean(site)),
)
return sites.size
}, [filtered])
const groupBySite = uniqueSites >= 2 && !q
const groups = useMemo(() => {
if (!groupBySite) {
return [{ site: "", items: filtered }]
}
const bySite = new Map<string, ServerTileItem[]>()
for (const item of filtered) {
const site = item.site?.trim() || "—"
const list = bySite.get(site)
if (list) list.push(item)
else bySite.set(site, [item])
}
return [...bySite.entries()].map(([site, siteItems]) => ({ site, items: siteItems }))
}, [filtered, groupBySite])
const showAllTile = showAll && !q
const resolvedId = useMemo(() => {
if (showAll && selectedId === ALL_SERVERS_ID) return ALL_SERVERS_ID
if (items.some((item) => item.id === selectedId)) return selectedId
if (showAll) return ALL_SERVERS_ID
return items.find(isSelectable)?.id ?? items[0]?.id ?? ""
}, [items, selectedId, showAll])
useEffect(() => {
if (resolvedId && resolvedId !== selectedId) onSelect(resolvedId)
}, [onSelect, resolvedId, selectedId])
const selectableIds = useMemo(() => {
const ids: string[] = []
if (showAllTile) ids.push(ALL_SERVERS_ID)
for (const item of filtered) {
if (isSelectable(item)) ids.push(item.id)
}
return ids
}, [filtered, showAllTile])
function moveSelection(delta: number) {
if (selectableIds.length === 0) return
const idx = selectableIds.indexOf(resolvedId)
const nextIdx =
idx < 0
? delta > 0 ? 0 : selectableIds.length - 1
: Math.min(selectableIds.length - 1, Math.max(0, idx + delta))
const nextId = selectableIds[nextIdx]
if (nextId) onSelect(nextId)
}
function handleListKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (loading) return
if (event.key === "ArrowDown") {
event.preventDefault()
moveSelection(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
moveSelection(-1)
return
}
if (event.key === "Home") {
event.preventDefault()
if (selectableIds[0]) onSelect(selectableIds[0])
return
}
if (event.key === "End") {
event.preventDefault()
const last = selectableIds[selectableIds.length - 1]
if (last) onSelect(last)
}
}
const allItem: ServerTileItem = {
id: ALL_SERVERS_ID,
name: "Все серверы",
title: "Все серверы",
count: allCount,
meta: String(allCount),
}
return (
<Frame dense spacing="sm" className={cn("flex h-full min-h-0 w-full flex-col", className)}>
<FramePanel className="flex min-h-0 flex-1 flex-col gap-0 p-0">
<FrameHeader className="flex flex-col gap-2 border-b px-3 py-2">
{showHeader ? (
<div className="flex items-center justify-between gap-2">
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Серверы
</FrameTitle>
<span className="flex items-center gap-1.5">
{loading ? <Spinner className="size-3.5 text-muted-foreground" /> : null}
{headerRight}
</span>
</div>
) : headerRight || loading ? (
<div className="flex justify-end gap-1.5">
{loading ? <Spinner className="size-3.5 text-muted-foreground" /> : null}
{headerRight}
</div>
) : null}
<InputGroup>
<InputGroupAddon>
<SearchIcon className="size-3.5" />
</InputGroupAddon>
<InputGroupInput
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Поиск…"
aria-label="Поиск сервера"
disabled={loading}
/>
</InputGroup>
</FrameHeader>
<ScrollArea className="min-h-0 flex-1">
<div
ref={listRef}
className="flex flex-col gap-0.5 p-1.5"
role="listbox"
aria-label="Серверы"
aria-activedescendant={resolvedId ? `server-tile-${resolvedId}` : undefined}
tabIndex={0}
onKeyDown={handleListKeyDown}
>
{showAllTile ? (
<ServerTileButton
item={allItem}
selected={resolvedId === ALL_SERVERS_ID}
onSelect={() => onSelect(ALL_SERVERS_ID)}
showCount={showCount}
showType={false}
icon={
<IconTile variant="elevated" size="sm" aria-hidden="true">
<LayersIcon className="text-muted-foreground" />
</IconTile>
}
/>
) : null}
{groups.map((group) => (
<div key={group.site || "__flat__"} className="flex flex-col gap-0.5">
{groupBySite && group.site ? (
<p className="sticky top-0 z-[1] bg-background/95 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-muted-foreground backdrop-blur-sm">
{group.site}
</p>
) : null}
{group.items.map((item) => (
<ServerTileButton
key={item.id}
item={item}
selected={resolvedId === item.id}
onSelect={() => onSelect(item.id)}
showCount={showCount}
showType={showType}
showSite={!groupBySite}
icon={
<IconTile variant="elevated" size="sm" aria-hidden="true">
{item.country ? (
<Flag code={item.country} size={16} />
) : (
<ServerIcon className="text-muted-foreground" />
)}
</IconTile>
}
/>
))}
</div>
))}
{filtered.length === 0 && !showAllTile ? (
<p className="px-2 py-3 text-center text-xs text-muted-foreground">
{loading
? "Загрузка серверов…"
: items.length === 0
? "Нет доступных серверов"
: "Ничего не найдено"}
</p>
) : null}
</div>
</ScrollArea>
</FramePanel>
</Frame>
)
}
function ServerTileButton({
item,
selected,
onSelect,
showCount,
showType,
showSite = true,
icon,
}: {
item: ServerTileItem
selected: boolean
onSelect: () => void
showCount: boolean
showType: boolean
showSite?: boolean
icon: ReactNode
}) {
const isAll = item.id === ALL_SERVERS_ID
const selectable = isSelectable(item)
const hostname = isAll ? "Все серверы" : hostnameOf(item)
const meta = item.meta ?? (showCount && item.count != null ? String(item.count) : undefined)
const showStatus = Boolean(item.status && item.status !== "online")
return (
<Item
id={`server-tile-${item.id}`}
size="xs"
variant={selected ? "muted" : "default"}
render={
<button
type="button"
role="option"
aria-selected={selected}
title={item.title ?? hostname}
disabled={!selectable}
onClick={onSelect}
/>
}
className={cn(
"h-11 min-h-11 flex-nowrap rounded-md py-0",
selected && "ring-1 ring-border",
item.enabled === false && !selected && "opacity-40",
!selectable && "cursor-not-allowed opacity-40 hover:bg-transparent",
)}
>
<ItemMedia>{icon}</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
{showStatus ? <StatusDot status={item.status!} /> : null}
<span className="min-w-0 truncate">{hostname}</span>
{!isAll && showSite && item.site ? (
<Badge variant="outline" size="xs" className="shrink-0 font-mono uppercase">
{item.site}
</Badge>
) : null}
{!isAll && showType && item.type ? <ServerTypeBadge type={item.type} /> : null}
</ItemTitle>
</ItemContent>
{meta ? (
<ItemActions>
<Badge
variant={selected ? "secondary" : "outline"}
size="xs"
className="tabular-nums"
>
{meta}
</Badge>
</ItemActions>
) : null}
</Item>
)
}
export { ServerTileRail, ServerTypeBadge, hostnameOf }
+372
View File
@@ -0,0 +1,372 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import * as RechartsPrimitive from "recharts"
import type { TooltipValueType } from "recharts"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
type TooltipNameType = number | string
export type ChartConfig = Record<
string,
{
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
>
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
initialDimension?: {
width: number
height: number
}
}) {
const uniqueId = React.useId()
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme ?? config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
"accessibilityLayer"
>) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? (config[label]?.label ?? label)
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color ?? item.payload?.fill ?? item.color
return (
<div
key={index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean
nameKey?: string
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={index}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config ? config[configLabelKey] : config[key]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+300
View File
@@ -0,0 +1,300 @@
"use client"
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
render={<ComboboxTrigger />}
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className,
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
className,
)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className,
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}
+203
View File
@@ -0,0 +1,203 @@
"use client"
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn(
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
className
)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-2", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
{
variants: {
variant: {
default: "border-transparent",
outline: "border-border",
muted: "border-transparent bg-muted/50",
},
size: {
default: "gap-2.5 px-3 py-2.5",
sm: "gap-2.5 px-3 py-2.5",
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
render,
...props
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(itemVariants({ variant, size, className })),
},
props
),
render,
state: {
slot: "item",
variant,
size,
},
})
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "[&_svg:not([class*='size-'])]:size-4",
image:
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}
+9 -8
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit" import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -51,30 +51,31 @@ function WgCreateSheet({
onOpenChange, onOpenChange,
servers, servers,
busy, busy,
defaultServerId,
onSubmit, onSubmit,
}: { }: {
open: boolean open: boolean
onOpenChange: (v: boolean) => void onOpenChange: (v: boolean) => void
servers: ServerOption[] servers: ServerOption[]
busy?: boolean busy?: boolean
defaultServerId?: string
onSubmit: (form: WgCreateFormState) => void | Promise<void> onSubmit: (form: WgCreateFormState) => void | Promise<void>
}) { }) {
const [form, setForm] = useState<WgCreateFormState>(defaultWgCreateForm) const [form, setForm] = useState<WgCreateFormState>(defaultWgCreateForm)
const set = <K extends keyof WgCreateFormState>(k: K, v: WgCreateFormState[K]) => const set = <K extends keyof WgCreateFormState>(k: K, v: WgCreateFormState[K]) =>
setForm((f) => ({ ...f, [k]: v })) setForm((f) => ({ ...f, [k]: v }))
useEffect(() => {
if (!open) return
setForm({ ...defaultWgCreateForm(), serverId: defaultServerId ?? "" })
}, [open, defaultServerId])
const canSubmit = useMemo(() => { const canSubmit = useMemo(() => {
return Boolean(form.serverId && form.name.trim() && form.listenPort) return Boolean(form.serverId && form.name.trim() && form.listenPort)
}, [form.serverId, form.name, form.listenPort]) }, [form.serverId, form.name, form.listenPort])
return ( return (
<Sheet <Sheet open={open} onOpenChange={onOpenChange}>
open={open}
onOpenChange={(v) => {
if (v) setForm(defaultWgCreateForm())
onOpenChange(v)
}}
>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0"> <SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0"> <SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Быстрый туннель WireGuard</SheetTitle> <SheetTitle>Быстрый туннель WireGuard</SheetTitle>
+88 -160
View File
@@ -1,29 +1,16 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { useMemo } from "react"
import type { WgIfaceWithServer } from "@/components/data-grids/wireguard-data-grid" import type { WgIfaceWithServer } from "@/components/data-grids/wireguard-data-grid"
import { Button } from "@/components/ui/button"
import { import {
Sheet, SheetContent, SheetHeader, SheetTitle, CodeExportSheet,
SheetDescription, SheetFooter, SheetClose, type CodeExportFormat,
} from "@/components/ui/sheet" } from "@/components/reui-kit/code-export-sheet"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { import {
generateMikrotikRsc, generateMikrotikRsc,
generateNativeConf, generateNativeConf,
generatePeerClientConf, generatePeerClientConf,
} from "@/lib/wg-config" } from "@/lib/wg-config"
import { CheckIcon, CopyIcon, DownloadIcon } from "lucide-react"
function downloadText(filename: string, content: string) {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
function WgExportSheet({ function WgExportSheet({
open, open,
@@ -32,27 +19,34 @@ function WgExportSheet({
liveContent, liveContent,
liveBusy, liveBusy,
onRequestLiveExport, onRequestLiveExport,
initialTab = "rsc",
peerId,
}: { }: {
open: boolean open: boolean
iface: WgIfaceWithServer | null iface: WgIfaceWithServer | null
onClose: () => void onClose: () => void
/** Optional server-fetched content (with private key) keyed by format */
liveContent?: { rsc?: string; conf?: string; peerConf?: string } | null liveContent?: { rsc?: string; conf?: string; peerConf?: string } | null
liveBusy?: boolean liveBusy?: boolean
onRequestLiveExport?: (format: "rsc" | "conf" | "peer-conf") => void onRequestLiveExport?: (format: "rsc" | "conf" | "peer-conf") => void
initialTab?: "rsc" | "conf" | "peer"
/** Selected peer for Peer .conf (defaults to first peer) */
peerId?: string | null
}) { }) {
const [tab, setTab] = useState<"rsc" | "conf" | "peer">("rsc") const formats = useMemo((): CodeExportFormat[] => {
const [copied, setCopied] = useState(false) if (!iface) {
return [
useEffect(() => { { id: "rsc", label: ".rsc", filename: "wg.rsc", code: "" },
if (open) { { id: "conf", label: ".conf", filename: "wg.conf", code: "" },
setTab("rsc") {
setCopied(false) id: "peer",
label: "Peer",
filename: "wg-peer.conf",
code: "",
emptyMessage: "Интерфейс не выбран",
},
]
} }
}, [open, iface?.id])
const local = useMemo(() => {
if (!iface) return { rsc: "", conf: "", peerConf: "" }
const base = { const base = {
name: iface.name, name: iface.name,
listenPort: iface.listenPort, listenPort: iface.listenPort,
@@ -76,143 +70,77 @@ function WgExportSheet({
clientEndpoint: p.clientEndpoint, clientEndpoint: p.clientEndpoint,
})), })),
} }
const peer = iface.peers[0]
return { const peer =
rsc: generateMikrotikRsc(base), (peerId
conf: generateNativeConf(base), ? iface.peers.find((p) => p.id === peerId || p.rosId === peerId)
peerConf: : undefined) ?? iface.peers[0]
iface.publicKey && peer
? generatePeerClientConf({ const localRsc = generateMikrotikRsc(base)
peerAddress: peer.clientAddress, const localConf = generateNativeConf(base)
peerDns: peer.clientDns,
serverPublicKey: iface.publicKey, let peerConf = ""
allowedIps: peer.allowedIps, let peerEmpty: string | undefined
endpoint: if (!iface.publicKey) {
peer.clientEndpoint || peerEmpty = "Нет public-key интерфейса для клиентского .conf"
peer.endpoint || } else if (!peer) {
undefined, peerEmpty = "Нет пиров для экспорта Peer .conf"
persistentKeepalive: peer.persistentKeepalive ?? 25, } else {
}) peerConf = generatePeerClientConf({
: "# Нет public-key интерфейса или пиров для клиентского .conf\n", peerAddress: peer.clientAddress,
peerDns: peer.clientDns,
serverPublicKey: iface.publicKey,
allowedIps: peer.allowedIps,
endpoint: peer.clientEndpoint || peer.endpoint || undefined,
persistentKeepalive: peer.persistentKeepalive ?? 25,
})
} }
}, [iface])
const code = return [
tab === "rsc" {
? (liveContent?.rsc ?? local.rsc) id: "rsc",
: tab === "conf" label: ".rsc",
? (liveContent?.conf ?? local.conf) filename: `${iface.name}.rsc`,
: (liveContent?.peerConf ?? local.peerConf) code: liveContent?.rsc ?? localRsc,
isLive: Boolean(liveContent?.rsc),
const filename = },
tab === "rsc" {
? `${iface?.name ?? "wg"}.rsc` id: "conf",
: tab === "conf" label: ".conf",
? `${iface?.name ?? "wg"}.conf` filename: `${iface.name}.conf`,
: `${iface?.name ?? "wg"}-peer.conf` code: liveContent?.conf ?? localConf,
isLive: Boolean(liveContent?.conf),
function handleCopy() { },
void navigator.clipboard.writeText(code).then(() => { {
setCopied(true) id: "peer",
setTimeout(() => setCopied(false), 2000) label: "Peer",
}) filename: `${iface.name}-peer.conf`,
} code: liveContent?.peerConf ?? peerConf,
emptyMessage: liveContent?.peerConf ? undefined : peerEmpty,
isLive: Boolean(liveContent?.peerConf),
},
]
}, [iface, liveContent, peerId])
return ( return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}> <CodeExportSheet
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl"> open={open}
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b"> onClose={onClose}
<div className="flex items-start justify-between gap-4"> title="Экспорт WireGuard"
<div> description={iface ? `${iface.name} · ${iface.serverName}` : "—"}
<SheetTitle>Экспорт WireGuard</SheetTitle> formats={formats}
<SheetDescription> initialFormatId={initialTab}
{iface ? `${iface.name} · ${iface.serverName}` : "—"} liveBusy={liveBusy}
</SheetDescription> liveLabel="С роутера"
</div> onLiveFetch={
<div className="flex gap-2 shrink-0"> onRequestLiveExport
<Button variant="outline" size="sm" onClick={handleCopy}> ? (formatId) =>
{copied onRequestLiveExport(
? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> formatId === "peer" ? "peer-conf" : formatId === "conf" ? "conf" : "rsc",
: <><CopyIcon className="size-3.5" />Копировать</>}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => downloadText(filename, code)}
>
<DownloadIcon className="size-3.5" />
Файл
</Button>
</div>
</div>
</SheetHeader>
<div className="px-6 pt-3 shrink-0">
<Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)}>
<TabsList>
<TabsTrigger value="rsc">MikroTik .rsc</TabsTrigger>
<TabsTrigger value="conf">Native .conf</TabsTrigger>
<TabsTrigger value="peer">Peer .conf</TabsTrigger>
</TabsList>
{onRequestLiveExport && (
<div className="mt-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={liveBusy}
onClick={() =>
onRequestLiveExport(
tab === "peer" ? "peer-conf" : tab === "conf" ? "conf" : "rsc",
)
}
>
{liveBusy ? "Загрузка с роутера…" : "Подтянуть с роутера (с private-key)"}
</Button>
</div>
)}
<TabsContent value="rsc" className="mt-0" />
<TabsContent value="conf" className="mt-0" />
<TabsContent value="peer" className="mt-0" />
</Tabs>
</div>
<div className="flex-1 overflow-y-auto">
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isCmd = line.trimStart().startsWith("/interface") || line.trimStart().startsWith("/ip")
const isSection = line.startsWith("[")
const isParam = /^\s+[a-z]/.test(line) || /^[A-Za-z]+=/.test(line)
return (
<span
key={i}
className={
isComment
? "text-muted-foreground"
: isCmd || isSection
? "text-sky-400"
: isParam
? "text-violet-300"
: "text-foreground"
}
>
{line}{"\n"}
</span>
) )
})} : undefined
</pre> }
</div> />
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1" onClick={handleCopy}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
) )
} }
+11 -2
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { FormField, SectionTitle } from "@/components/form-kit" import { FormField, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
@@ -17,12 +17,14 @@ function WgImportSheet({
onOpenChange, onOpenChange,
servers, servers,
busy, busy,
defaultServerId,
onImport, onImport,
}: { }: {
open: boolean open: boolean
onOpenChange: (v: boolean) => void onOpenChange: (v: boolean) => void
servers: ServerOption[] servers: ServerOption[]
busy?: boolean busy?: boolean
defaultServerId?: string
onImport: (args: { onImport: (args: {
serverId: string serverId: string
content: string content: string
@@ -41,6 +43,11 @@ function WgImportSheet({
[content], [content],
) )
useEffect(() => {
if (!open) return
setServerId(defaultServerId ?? "")
}, [open, defaultServerId])
function runPreview() { function runPreview() {
setParseError(null) setParseError(null)
setPreview(null) setPreview(null)
@@ -66,7 +73,9 @@ function WgImportSheet({
<Sheet <Sheet
open={open} open={open}
onOpenChange={(v) => { onOpenChange={(v) => {
if (!v) { if (v) {
setServerId(defaultServerId ?? "")
} else {
setContent("") setContent("")
setPreview(null) setPreview(null)
setParseError(null) setParseError(null)
+83
View File
@@ -0,0 +1,83 @@
# UI Design Contract (MikrotikManager)
Surface: **ReUI Frame**. Kit: `components/reui-kit/`.
Иерархия: **ReUI PRO > shadcn primitives**.
Стек: Next.js 16 App Router + Turbopack + shadcn **base-nova** + ReUI `@reui`.
Карта: [docs](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [Get Started](https://reui.io/docs/get-started) · [Styling](https://reui.io/docs/styling) · [Blocks](https://reui.io/blocks) · [MCP](https://reui.io/docs/mcp)
## Surface
Project lock: **`surface: frame`**. Ops / list / dashboard / detail / settings — только **Frame**, не shadcn Card как page shell. Не смешивать Card и Frame на одном ops-экране.
Эталон CRUD: [`app/(main)/servers`](../app/(main)/servers).
Оболочка списков: `DataPageCard` → Frame. Панели: `OpsPanel` → Frame.
## Canonical PRO references
| Зона | Preview |
|------|---------|
| Shell | https://reui.io/preview/base/app-shell-12 |
| KPI | https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile |
| Lists | https://reui.io/preview/base/data-grid-filtering-2 |
| Server rail | https://reui.io/preview/base/list-9 · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile |
| Settings | https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3 |
| Forms / Sheet | https://reui.io/preview/base/form-7 · https://reui.io/preview/base/sheet-8 |
| Alert / Badge | https://reui.io/docs/components/base/alert · https://reui.io/docs/components/base/badge |
| Empty | https://reui.io/preview/base/empty-state-12 |
## Kit (`components/reui-kit/`)
| Component | Role |
|-----------|------|
KPI: только `KpiStatGrid` (hybrid IconTile elevated `size-10.5`, эталон `/wireguard`). Не hand-roll Frame KPI.
| `CodeExportSheet` | Экспорт кода (.rsc / .conf) — Sheet + Frame + ScrollArea |
Переключение сервера (ops-контекст): **`ServerTileRail` + `ServerRailLayout`** (`w-60` = `--sidebar-width`). Desktop rail сворачивается (persist `mm:server-rail-collapsed`); mobile — Sheet. Не Combobox. Страницы: OSPF, Filters, GRE, Recursive Routes, WireGuard, Terminal, Certificates, Probes, **Firewall** (live `/api/firewall`). Combobox — generic primitive, не доменный селектор хостов.
Слои:
| Слой | Путь |
|------|------|
| shadcn | `components/ui/` |
| ReUI CLI | `components/reui/` |
| Kit | `components/reui-kit/` |
| Domain grids | `components/data-grids/` |
## Shared App Shell chrome
| Токен / зона | Значение |
|--------------|----------|
| `--sidebar-width` | `240px` |
| Header right | AppsMenu → SystemMonitorPopover (тема — в NavUser) |
| Search | ⌘K / Ctrl+K only |
| `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` |
## Spacing & tokens
- `flex` + `gap-*` — не `space-y-*` / `space-x-*`
- Semantic tokens / ReUI Badge variants — не raw `bg-emerald-*` / hex
- Max 1 primary CTA на экран
## Exceptions (не Frame ops shell)
- `network-map` — canvas / topology UX
- `terminal` — terminal chrome
## Forbidden
- Card как ops list/dashboard shell
- Hand-roll data-grid / KPI / code-export при наличии kit
- Дубль Copy в header + footer export sheet
- Mixing Card и Frame на одном ops-экране
- Radix-варианты docs — только Base UI (`base-nova`)
- Combobox / toolbar-фильтр как переключатель сервера (SoT — `ServerTileRail`)
## License
```env
# .env.local (gitignored)
REUI_LICENSE_KEY=
```
`components.json` → `@reui` с `Authorization: Bearer ${REUI_LICENSE_KEY}`.
+93
View File
@@ -0,0 +1,93 @@
"use client"
import { useEffect, useState } from "react"
import { resolveApiUrl, withAuthHeaders } from "@/shared/api/http-client"
export interface TrafficLiveSample {
rxMbps: number
txMbps: number
at: string
}
function parseSseBlock(block: string): { event: string; data: string } {
let event = "message"
const dataLines: string[] = []
for (const line of block.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim()
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim())
}
return { event, data: dataLines.join("\n") }
}
export function useTrafficLive(opts: {
enabled: boolean
backendUrl: string
serverId: string
iface: string
}): { sample: TrafficLiveSample | null; error: string | null } {
const [sample, setSample] = useState<TrafficLiveSample | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!opts.enabled || !opts.serverId) {
setSample(null)
setError(null)
return
}
const ac = new AbortController()
setSample(null)
setError(null)
const ifaceQ =
opts.iface && opts.iface !== "__all__"
? `?iface=${encodeURIComponent(opts.iface)}`
: ""
const path = `/api/traffic/servers/${encodeURIComponent(opts.serverId)}/live${ifaceQ}`
const url = resolveApiUrl(opts.backendUrl, path)
let buf = ""
setError(null)
void (async () => {
try {
const res = await fetch(url, {
headers: withAuthHeaders({ Accept: "text/event-stream" }),
signal: ac.signal,
credentials: "include",
})
if (!res.ok || !res.body) {
setError(`live HTTP ${res.status}`)
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
while (!ac.signal.aborted) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const parts = buf.split("\n\n")
buf = parts.pop() ?? ""
for (const raw of parts) {
if (!raw.trim() || raw.trim().startsWith(":")) continue
const ev = parseSseBlock(raw)
if (ev.event === "sample" && ev.data) {
const parsed = JSON.parse(ev.data) as TrafficLiveSample
setSample(parsed)
setError(null)
} else if (ev.event === "error" && ev.data) {
const parsed = JSON.parse(ev.data) as { error?: string }
setError(parsed.error ?? "live error")
}
}
}
} catch (e) {
if (ac.signal.aborted) return
setError(e instanceof Error ? e.message : "live error")
}
})()
return () => ac.abort()
}, [opts.enabled, opts.backendUrl, opts.serverId, opts.iface])
return { sample, error }
}
+30 -10
View File
@@ -146,6 +146,8 @@ export type FirewallAction =
| "fasttrack-connection" | "nfqueue" | "passthrough" | "return" | "fasttrack-connection" | "nfqueue" | "passthrough" | "return"
| "add-src-to-address-list" | "add-dst-to-address-list" | "add-src-to-address-list" | "add-dst-to-address-list"
export type FirewallTable = "filter" | "nat" | "mangle" | "raw"
export interface FirewallRule { export interface FirewallRule {
id: string id: string
chain: string chain: string
@@ -158,16 +160,32 @@ export interface FirewallRule {
comment: string comment: string
enabled: boolean enabled: boolean
hits: number hits: number
// RouterOS 7.x extras
family?: "ip" | "ip6" | "any" family?: "ip" | "ip6" | "any"
tlsHost?: string // tls-host matcher (SNI) table?: FirewallTable
connRateLimit?: string // connection-rate e.g. "100/s" serverId?: string
layer7Proto?: string // /ip firewall layer7-protocol serverName?: string
rosId?: string
tlsHost?: string
connRateLimit?: string
layer7Proto?: string
nfqueueId?: number nfqueueId?: number
log?: boolean log?: boolean
logPrefix?: string logPrefix?: string
} }
export interface FirewallAddressListEntry {
id: string
list: string
address: string
comment: string
disabled: boolean
timeout?: string
family?: "ip" | "ip6"
serverId?: string
serverName?: string
rosId?: string
}
export interface Backup { export interface Backup {
id: string id: string
server: string server: string
@@ -438,12 +456,14 @@ export const asns: Asn[] = [
] ]
export const firewallRules: FirewallRule[] = [ export const firewallRules: FirewallRule[] = [
{ id: "fw1", chain: "forward", action: "accept", proto: "tcp", src: "youtube-bypass", dst: "0.0.0.0/0", port: "443", iface: "wan-bgp", comment: "YouTube bypass traffic", enabled: true, hits: 188421 }, { id: "fw1", table: "filter", family: "ip", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*1", chain: "forward", action: "accept", proto: "tcp", src: "youtube-bypass", dst: "0.0.0.0/0", port: "443", iface: "wan-bgp", comment: "YouTube bypass traffic", enabled: true, hits: 188421 },
{ id: "fw2", chain: "forward", action: "mark-routing", proto: "tcp", src: "10.10.0.0/24", dst: "cdn-bypass", port: "—", iface: "lan", comment: "CDN routing mark", enabled: true, hits: 4128821 }, { id: "fw2", table: "mangle", family: "ip", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*1", chain: "forward", action: "mark-routing", proto: "tcp", src: "10.10.0.0/24", dst: "cdn-bypass", port: "—", iface: "lan", comment: "CDN routing mark", enabled: true, hits: 4128821 },
{ id: "fw3", chain: "srcnat", action: "masquerade", proto: "all", src: "10.10.0.0/16", dst: "0.0.0.0/0", port: "—", iface: "wan-msk", comment: "Main NAT rule", enabled: true, hits: 12889241 }, { id: "fw3", table: "nat", family: "ip", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*1", chain: "srcnat", action: "masquerade", proto: "all", src: "10.10.0.0/16", dst: "0.0.0.0/0", port: "—", iface: "wan-msk", comment: "Main NAT rule", enabled: true, hits: 12889241 },
{ id: "fw4", chain: "forward", action: "drop", proto: "tcp", src: "0.0.0.0/0", dst: "social-block-school", port: "—", iface: "lan-school", comment: "School social block", enabled: true, hits: 4218 }, { id: "fw4", table: "filter", family: "ip", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*2", chain: "forward", action: "drop", proto: "tcp", src: "0.0.0.0/0", dst: "social-block-school", port: "—", iface: "lan-school", comment: "School social block", enabled: true, hits: 4218 },
{ id: "fw5", chain: "input", action: "accept", proto: "tcp", src: "10.10.0.0/16", dst: "—", port: "22,443,8291",iface: "—", comment: "Management access", enabled: true, hits: 18412 }, { id: "fw5", table: "filter", family: "ip", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*3", chain: "input", action: "accept", proto: "tcp", src: "10.10.0.0/16", dst: "—", port: "22,443,8291",iface: "—", comment: "Management access", enabled: true, hits: 18412 },
{ id: "fw6", chain: "input", action: "drop", proto: "tcp", src: "0.0.0.0/0", dst: "—", port: "22", iface: "wan-msk", comment: "Block SSH from WAN", enabled: true, hits: 882412 }, { id: "fw6", table: "filter", family: "ip", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*4", chain: "input", action: "drop", proto: "tcp", src: "0.0.0.0/0", dst: "—", port: "22", iface: "wan-msk", comment: "Block SSH from WAN", enabled: true, hits: 882412 },
{ id: "fw7", table: "filter", family: "ip6", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*1", chain: "input", action: "accept", proto: "icmpv6", src: "fe80::/10", dst: "—", port: "—", iface: "—", comment: "IPv6 link-local ICMPv6", enabled: true, hits: 1204 },
{ id: "fw8", table: "filter", family: "ip6", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*2", chain: "forward", action: "accept", proto: "tcp", src: "2a01:4f8::/32", dst: "::/0", port: "443", iface: "wan-bgp", comment: "IPv6 HTTPS forward", enabled: true, hits: 88421 },
] ]
export const backups: Backup[] = [ export const backups: Backup[] = [
+28
View File
@@ -0,0 +1,28 @@
/** Формат скорости: Кбит/с при < 1 Мбит/с, иначе Мбит/с / Гбит/с. */
export function fmtRate(v: number): string {
if (!Number.isFinite(v) || v <= 0) return "0 Кбит/с"
if (v >= 1000) return `${(v / 1000).toFixed(2)} Гбит/с`
if (v < 1) return `${Math.round(v * 1000)} Кбит/с`
if (v < 10) return `${v.toFixed(2)} Мбит/с`
if (v < 100) return `${v.toFixed(1)} Мбит/с`
return `${Math.round(v)} Мбит/с`
}
export function fmtGB(v: number): string {
if (v >= 1000) return `${(v / 1000).toFixed(2)} ТБ`
return `${v.toFixed(1)} ГБ`
}
export const TRAFFIC_RANGE_MINUTES: Record<string, number> = {
"5m": 5,
"15m": 15,
"1h": 60,
"4h": 240,
"24h": 1440,
}
export function formatRangeAgo(minutesAgo: number): string {
if (minutesAgo <= 0) return "сейчас"
if (minutesAgo < 60) return `−${minutesAgo}м`
return `−${Math.round(minutesAgo / 60)}ч`
}
+385 -1
View File
@@ -24,10 +24,12 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"cn": "^0.2.5",
"lucide-react": "^1.11.0", "lucide-react": "^1.11.0",
"next": "16.2.4", "next": "16.2.4",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"recharts": "^3.8.0",
"shadcn": "^4.5.0", "shadcn": "^4.5.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
@@ -3694,6 +3696,42 @@
} }
} }
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.18",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
"integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -3719,6 +3757,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -4219,6 +4269,69 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -4284,6 +4397,12 @@
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/validate-npm-package-name": { "node_modules/@types/validate-npm-package-name": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
@@ -5777,6 +5896,18 @@
"react-dom": "^18 || ^19 || ^19.0.0-rc" "react-dom": "^18 || ^19 || ^19.0.0-rc"
} }
}, },
"node_modules/cn": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/cn/-/cn-0.2.5.tgz",
"integrity": "sha512-OCjZtMeQfXbI4Es1+EIjkd77gvWzaE689gD8KhfexlqjClC06qR1MQBR+Z35ZMSPNEBWyHiItW1Soy0UvwNv9w==",
"license": "MIT",
"bin": {
"cn": "bin/cn.mjs"
},
"engines": {
"node": ">=20"
}
},
"node_modules/code-block-writer": { "node_modules/code-block-writer": {
"version": "13.0.3", "version": "13.0.3",
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
@@ -5958,6 +6089,127 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -6055,6 +6307,12 @@
} }
} }
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/decompress-response": { "node_modules/decompress-response": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -6684,6 +6942,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/es-toolkit": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks",
"tests/types",
"tests/browser-compat"
]
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -7182,6 +7452,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/eventsource": { "node_modules/eventsource": {
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@@ -8350,6 +8626,16 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -8403,6 +8689,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@@ -11031,9 +11326,31 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": { "node_modules/react-remove-scroll": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -11142,6 +11459,51 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/recharts": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect-metadata": { "node_modules/reflect-metadata": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@@ -13487,6 +13849,28 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/web-streams-polyfill": { "node_modules/web-streams-polyfill": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
+2
View File
@@ -26,10 +26,12 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"cn": "^0.2.5",
"lucide-react": "^1.11.0", "lucide-react": "^1.11.0",
"next": "16.2.4", "next": "16.2.4",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"recharts": "^3.8.0",
"shadcn": "^4.5.0", "shadcn": "^4.5.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
+1 -1
View File
@@ -1 +1 @@
{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.es2024.d.ts","./node_modules/typescript/lib/lib.esnext.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2024.collection.d.ts","./node_modules/typescript/lib/lib.es2024.object.d.ts","./node_modules/typescript/lib/lib.es2024.promise.d.ts","./node_modules/typescript/lib/lib.es2024.regexp.d.ts","./node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2024.string.d.ts","./node_modules/typescript/lib/lib.esnext.array.d.ts","./node_modules/typescript/lib/lib.esnext.collection.d.ts","./node_modules/typescript/lib/lib.esnext.intl.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.esnext.promise.d.ts","./node_modules/typescript/lib/lib.esnext.decorators.d.ts","./node_modules/typescript/lib/lib.esnext.iterator.d.ts","./node_modules/typescript/lib/lib.esnext.float16.d.ts","./node_modules/typescript/lib/lib.esnext.error.d.ts","./node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/next/dist/styled-jsx/types/css.d.ts","./node_modules/next/dist/styled-jsx/types/macro.d.ts","./node_modules/next/dist/styled-jsx/types/style.d.ts","./node_modules/next/dist/styled-jsx/types/global.d.ts","./node_modules/next/dist/styled-jsx/types/index.d.ts","./node_modules/next/dist/server/get-page-files.d.ts","./node_modules/@types/node/compatiLine truncated {"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.es2024.d.ts","./node_modules/typescript/lib/lib.esnext.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2024.collection.d.ts","./node_modules/typescript/lib/lib.es2024.object.d.ts","./node_modules/typescript/lib/lib.es2024.promise.d.ts","./node_modules/typescript/lib/lib.es2024.regexp.d.ts","./node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2024.string.d.ts","./node_modules/typescript/lib/lib.esnext.array.d.ts","./node_modules/typescript/lib/lib.esnext.collection.d.ts","./node_modules/typescript/lib/lib.esnext.intl.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.esnext.promise.d.ts","./node_modules/typescript/lib/lib.esnext.decorators.d.ts","./node_modules/typescript/lib/lib.esnext.iterator.d.ts","./node_modules/typescript/lib/lib.esnext.float16.d.ts","./node_modules/typescript/lib/lib.esnext.error.d.ts","./node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/next/dist/styled-jsx/types/css.d.ts","./node_modules/next/dist/styled-jsx/types/macro.d.ts","./node_modules/next/dist/styled-jsx/types/style.d.ts","./node_modules/next/dist/styled-jsx/types/global.d.ts","./node_modules/next/dist/styled-jsx/types/index.d.ts","./node_modules/next/dist/server/get-page-files.d.ts","./node_modules/@types/node/compatiLine truncated