fix(ui): выровнять chrome Frame и убрать Card-оболочки
Docker images / prepare-release (push) Successful in 4s
Docker images / backend-image (push) Failing after 2m36s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 55s
Docker images / publish-release (push) Skipped

Подключить App Switcher, NavUser, OpsPanel и AlertDialog вместо Card-shell.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-17 15:36:56 +07:00
co-authored by Cursor
parent d2de8d3188
commit 5f29cfaf0f
47 changed files with 1972 additions and 860 deletions
+71 -50
View File
@@ -4,11 +4,21 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useDataSource } from "@/lib/data-source"
import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { OpsPanel } from "@/components/ops-panel"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Separator } from "@/components/ui/separator"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter,
} from "@/components/ui/sheet"
@@ -841,12 +851,9 @@ function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTele
}
return (
<Card>
<CardHeader className="pb-3 pt-4 px-4">
<CardTitle className="text-sm flex items-center justify-between">
<span className="flex items-center gap-2">
<span className="text-base"></span> Telegram
</span>
<OpsPanel
title={<span className="flex items-center gap-2"><span className="text-base"></span> Telegram</span>}
headerRight={
<span className={cn(
"flex items-center gap-1.5 text-[11px] font-normal px-2 py-0.5 rounded-full",
cfg.connected
@@ -856,9 +863,9 @@ function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTele
<span className={cn("size-1.5 rounded-full", cfg.connected ? "bg-emerald-500" : "bg-muted-foreground")} />
{cfg.connected ? "Подключён" : "Не настроен"}
</span>
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-3">
}
contentClassName="px-4 pb-4 flex flex-col gap-3"
>
{/* bot token */}
<div>
@@ -943,8 +950,7 @@ function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTele
: testResult === "fail" ? "Ошибка отправки"
: "Отправить тестовое сообщение"}
</Button>
</CardContent>
</Card>
</OpsPanel>
)
}
@@ -952,11 +958,7 @@ function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTele
function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
return (
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm">Журнал срабатываний</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<OpsPanel title="Журнал срабатываний" contentClassName="px-4 pb-4">
{entries.length === 0 ? (
<p className="text-xs text-muted-foreground py-2">Нет срабатываний</p>
) : (
@@ -983,8 +985,7 @@ function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
))}
</div>
)}
</CardContent>
</Card>
</OpsPanel>
)
}
@@ -2363,6 +2364,7 @@ export default function AlertsPage() {
const [meta, setMeta] = useState<AlertsMeta | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [pendingPresetReplaceId, setPendingPresetReplaceId] = useState<string | null>(null)
const [sheetSnap, setSheetSnap] = useState<RuleFormSnapshot | null>(null)
const [sheetEditId, setSheetEditId] = useState<string | null>(null)
const [sheetKey, setSheetKey] = useState(0)
@@ -2661,9 +2663,6 @@ export default function AlertsPage() {
const applyPresetReplace = useCallback(
(presetId: string) => {
if (typeof window !== "undefined" && !window.confirm("Заменить все текущие правила выбранным пресетом?")) {
return
}
const def = ALERT_PRESETS.find((p) => p.id === presetId)
if (!def) return
const built = def.build(serverNamesForPresets)
@@ -2947,11 +2946,7 @@ export default function AlertsPage() {
<CollapsibleContent>
<div className="border-t border-border/60 px-4 pb-4 pt-3">
<div className="grid gap-5 lg:grid-cols-2">
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm">Пресеты</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 flex flex-col gap-3">
<OpsPanel title="Пресеты" contentClassName="px-4 pb-4 flex flex-col gap-3">
<p className="text-[10px] text-muted-foreground leading-snug">
Быстро добавить набор правил из каталога серверов (демо или live meta).
</p>
@@ -2979,19 +2974,18 @@ export default function AlertsPage() {
variant="outline"
className="h-7 text-xs"
disabled={rulesSaveBusy || (isLive && backendStatus === false)}
onClick={() => applyPresetReplace(p.id)}
onClick={() => setPendingPresetReplaceId(p.id)}
>
Заменить все
</Button>
</div>
</div>
))}
</CardContent>
</Card>
</OpsPanel>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2 pb-2 pt-4 px-4">
<CardTitle className="text-sm">Группы ANY / ALL</CardTitle>
<OpsPanel
title="Группы ANY / ALL"
headerRight={
<Button
type="button"
size="sm"
@@ -3002,8 +2996,9 @@ export default function AlertsPage() {
>
<PlusIcon className="size-3.5" />Группа
</Button>
</CardHeader>
<CardContent className="px-4 pb-4 flex flex-col gap-3">
}
contentClassName="px-4 pb-4 flex flex-col gap-3"
>
{groups.length === 0 ? (
<p className="text-[11px] text-muted-foreground">
Групп пока нет. Создайте группу и назначьте правилам её в форме правила движок отправит одно сообщение по логике ANY или ALL.
@@ -3074,8 +3069,7 @@ export default function AlertsPage() {
</div>
))
)}
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
</CollapsibleContent>
@@ -3085,13 +3079,14 @@ export default function AlertsPage() {
<div className="grid grid-cols-1 xl:grid-cols-[1fr_300px] gap-5 items-start">
{/* ── rules card ── */}
<Card>
<CardHeader className="pb-0 pt-4 px-4">
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-sm flex items-center gap-2">
Правила оповещения
{rulesSaveBusy && <LoaderCircleIcon className="size-3.5 animate-spin text-muted-foreground" aria-hidden />}
</CardTitle>
<OpsPanel
title={
<span className="flex items-center gap-2">
Правила оповещения
{rulesSaveBusy && <LoaderCircleIcon className="size-3.5 animate-spin text-muted-foreground" aria-hidden />}
</span>
}
headerRight={
<Input
placeholder="Поиск…"
value={search}
@@ -3100,10 +3095,9 @@ export default function AlertsPage() {
name="alert-rules-search"
className="h-7 text-xs max-w-[200px]"
/>
</div>
</CardHeader>
<CardContent className="px-0 pb-0 pt-2">
}
contentClassName="px-0 pb-0 pt-2"
>
{filteredRules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground/40">
<BellOffIcon className="size-8 mb-2 opacity-40" />
@@ -3138,8 +3132,7 @@ export default function AlertsPage() {
<PlusIcon className="size-3" />Добавить правило
</button>
</div>
</CardContent>
</Card>
</OpsPanel>
{/* ── right sidebar ── */}
<div className="flex flex-col gap-5">
@@ -3172,6 +3165,34 @@ export default function AlertsPage() {
onTestRuleTelegram={isLive ? testRuleTelegramFromForm : undefined}
testTelegramDisabled={isLive && backendStatus === false}
/>
<AlertDialog
open={pendingPresetReplaceId != null}
onOpenChange={(open) => {
if (!open) setPendingPresetReplaceId(null)
}}
>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Заменить все правила?</AlertDialogTitle>
<AlertDialogDescription>
Текущие правила будут заменены выбранным пресетом. Это нельзя отменить.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
if (pendingPresetReplaceId) applyPresetReplace(pendingPresetReplaceId)
setPendingPresetReplaceId(null)
}}
>
Заменить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
+26 -27
View File
@@ -5,10 +5,12 @@ import { PageHeader } from "@/components/page-header"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { BackupsDataGrid } from "@/components/data-grids/backups-data-grid"
import { FileImportDialog } from "@/components/file-import-dialog"
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { StatusBadge } from "@/components/status-badge"
import type { Backup, Server } from "@/lib/data"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -349,15 +351,17 @@ export default function BackupsPage() {
{ label: "Вручную", value: manualCount, icon: <PlusIcon className="size-4" /> },
{ label: "Серверов охвачено",value: serverCount, icon: <ServerIcon className="size-4" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
<Frame key={s.label} className="h-full">
<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}
</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>
<span className="text-muted-foreground/40">{s.icon}</span>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
@@ -427,9 +431,7 @@ export default function BackupsPage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Расписание */}
<Card>
<CardContent className="px-5 py-5 flex flex-col gap-5">
<SectionTitle icon={<ClockIcon className="size-3.5" />}>Расписание</SectionTitle>
<OpsPanel title="Расписание" contentClassName="px-5 py-5 flex flex-col gap-5">
<div className="flex items-center justify-between">
<div>
@@ -520,13 +522,10 @@ export default function BackupsPage() {
</FormField>
</div>
</div>
</CardContent>
</Card>
</OpsPanel>
{/* Хранилище */}
<Card>
<CardContent className="px-5 py-5 flex flex-col gap-5">
<SectionTitle icon={<FolderIcon className="size-3.5" />}>Хранилище</SectionTitle>
<OpsPanel title="Хранилище" contentClassName="px-5 py-5 flex flex-col gap-5">
<FormField label="Тип хранилища">
<SegmentedControl
@@ -613,14 +612,13 @@ export default function BackupsPage() {
<p className="mt-1">Файлы: <span className="font-mono text-foreground">{storage.localPath || "/var/backup/mikrotik"}/{"<server-name>"}_{"{date}"}.{schedule.format}</span></p>
)}
</div>
</CardContent>
</Card>
</OpsPanel>
{/* Серверы */}
<Card className="lg:col-span-2">
<CardContent className="px-5 py-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<SectionTitle icon={<ServerIcon className="size-3.5" />}>Серверы для бэкапа</SectionTitle>
<OpsPanel
className="lg:col-span-2"
title="Серверы для бэкапа"
headerRight={
<div className="flex items-center gap-2 shrink-0">
<button type="button" onClick={() => setSelectedServers(new Set(liveServers.map(s => s.id)))}
className="text-xs text-primary hover:underline">Выбрать все</button>
@@ -628,7 +626,9 @@ export default function BackupsPage() {
<button type="button" onClick={() => setSelectedServers(new Set())}
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
</div>
</div>
}
contentClassName="px-5 py-5 flex flex-col gap-4"
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
{liveServers.map((s) => {
@@ -662,8 +662,7 @@ export default function BackupsPage() {
<p className="text-xs text-muted-foreground">
Выбрано {selectedServers.size} из {liveServers.length} серверов
</p>
</CardContent>
</Card>
</OpsPanel>
{/* Save button */}
<div className="lg:col-span-2 flex items-center gap-3">
+24 -30
View File
@@ -9,7 +9,8 @@ import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
import { BGP_AS_NAMES } from "@/lib/bgp/types"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -359,7 +360,8 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
const totalRx = router.sessions.reduce((a, s) => a + s.prefixesRx, 0)
return (
<Card key={router.id} className="overflow-hidden gap-0 py-0">
<Frame key={router.id} dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
{/* header */}
<div className="flex items-center gap-3 px-4 py-3 border-b">
<ServerIcon className="size-4 text-muted-foreground shrink-0" />
@@ -424,7 +426,8 @@ function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
</span>
</div>
)}
</Card>
</FramePanel>
</Frame>
)
})}
</div>
@@ -467,20 +470,18 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
{ label: "eBGP сессий", value: ebgpSessions, color: "" },
{ label: "iBGP сессий", value: ibgpSessions, color: "" },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className={cn("text-2xl font-semibold tabular-nums mt-0.5", s.color)}>{s.value}</p>
</CardContent>
</Card>
<Frame key={s.label} className="h-full">
<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>
</FramePanel>
</Frame>
))}
</div>
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5">
{/* prefixes by peer — horizontal bar chart */}
<Card>
<CardContent className="pt-4 pb-4 px-5">
<p className="text-sm font-semibold mb-4">Топ-8 пиров по полученным префиксам</p>
<OpsPanel title="Топ-8 пиров по полученным префиксам" contentClassName="pt-4 pb-4 px-5">
<div className="flex flex-col gap-3">
{topPeers.map((s, i) => {
const pct = (s.prefixesRx / maxRx) * 100
@@ -516,15 +517,12 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
<p className="text-sm text-muted-foreground text-center py-4">Нет данных о префиксах</p>
)}
</div>
</CardContent>
</Card>
</OpsPanel>
{/* right column */}
<div className="flex flex-col gap-4">
{/* state distribution */}
<Card>
<CardContent className="pt-4 pb-4 px-5">
<p className="text-sm font-semibold mb-3">Распределение состояний</p>
<OpsPanel title="Распределение состояний" contentClassName="pt-4 pb-4 px-5">
{stateCounts.length === 0 ? (
<p className="text-xs text-muted-foreground">Нет данных</p>
) : (
@@ -545,13 +543,10 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
})}
</div>
)}
</CardContent>
</Card>
</OpsPanel>
{/* eBGP vs iBGP */}
<Card>
<CardContent className="pt-4 pb-4 px-5">
<p className="text-sm font-semibold mb-3">eBGP vs iBGP</p>
<OpsPanel title="eBGP vs iBGP" contentClassName="pt-4 pb-4 px-5">
{sessions.length === 0 ? (
<p className="text-xs text-muted-foreground">Нет данных</p>
) : (
@@ -592,8 +587,7 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
</div>
</>
)}
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
</div>
@@ -739,12 +733,12 @@ export default function BgpPage() {
{ label: "Не установлено", value: notEstab, color: notEstab > 0 ? "text-amber-500" : "text-muted-foreground" },
{ label: "Получено префиксов", value: fmtNum(totalRx),color: "" },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className={cn("text-2xl font-semibold tabular-nums mt-0.5", s.color)}>{s.value}</p>
</CardContent>
</Card>
<Frame key={s.label} className="h-full">
<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>
</FramePanel>
</Frame>
))}
</div>
+25 -25
View File
@@ -7,7 +7,9 @@ import { FileImportDialog } from "@/components/file-import-dialog"
import { routerCertificates, servers as mockServers } from "@/lib/data"
import type { CertStatus, Server } from "@/lib/data"
import type { CertificateDto } from "@mmapp/contracts/certificates"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
@@ -140,15 +142,17 @@ function CertPartKpi({
icon: <AlertCircleIcon className="size-4 text-red-500" />,
},
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
<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>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
)
@@ -178,12 +182,11 @@ function CertPartAcmeSettings({
onSave: () => void
}) {
return (
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-3">
<p className="text-sm font-medium">ACME · Cloudflare DNS-01</p>
<p className="text-xs text-muted-foreground">
Публичные Let&apos;s Encrypt для зон в Cloudflare выпускаются на backend и импортируются на RouterOS 7.22+.
</p>
<OpsPanel
title="ACME · Cloudflare DNS-01"
description="Публичные Let's Encrypt для зон в Cloudflare выпускаются на backend и импортируются на RouterOS 7.22+."
contentClassName="px-5 py-4 flex flex-col gap-3"
>
<form
className="grid gap-3 md:grid-cols-2"
autoComplete="off"
@@ -232,19 +235,17 @@ function CertPartAcmeSettings({
Сохранить настройки
</Button>
</div>
</CardContent>
</Card>
</OpsPanel>
)
}
function CertPartReference() {
return (
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-1">
RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве.
</p>
<p className="text-xs font-medium text-muted-foreground mb-3">RouterOS 7 · /certificate справка CLI</p>
<OpsPanel
title="RouterOS 7 · /certificate — справка CLI"
description="RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве."
contentClassName="px-5 py-4"
>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
@@ -284,8 +285,7 @@ function CertPartReference() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
)
}
+30 -34
View File
@@ -10,9 +10,8 @@ import {
ACTION_LABELS,
ACTION_COLOR,
} from "@/components/data-grids/communities-data-grid"
import {
Card, CardContent, CardHeader, CardTitle, CardDescription,
} from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@@ -169,12 +168,12 @@ export default function CommunitiesPage() {
{ label: "Стандартных", value: String(listData.filter(c => c.type === "standard").length) },
{ label: "Использует фильтры",value: String(new Set(listData.flatMap(c => c.filterIds)).size) },
].map(({ label, value }) => (
<Card key={label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-2xl font-semibold tabular-nums">{value}</p>
</CardContent>
</Card>
<Frame key={label} className="h-full">
<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>
</FramePanel>
</Frame>
))}
</div>
@@ -211,30 +210,28 @@ export default function CommunitiesPage() {
{/* ── detail panel ── */}
{selected ? (
<Card className="h-fit sticky top-0">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div>
<div className="flex items-center gap-2 mb-1">
<span className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
selected.enabled
? "bg-emerald-500/10 text-emerald-600"
: "bg-muted text-muted-foreground"
)}>
{selected.enabled ? "Активен" : "Выключен"}
</span>
</div>
<CardTitle className="text-sm font-mono">{selected.value}</CardTitle>
<CardDescription className="text-xs mt-0.5">{selected.name}</CardDescription>
</div>
<OpsPanel
className="h-fit sticky top-0"
title={<span className="font-mono">{selected.value}</span>}
description={selected.name}
headerRight={
<div className="flex gap-1 shrink-0">
<Button size="sm" variant="ghost" className="size-7 p-0"><PencilIcon className="size-3.5" /></Button>
<Button size="sm" variant="ghost" className="size-7 p-0 text-destructive hover:text-destructive"><TrashIcon className="size-3.5" /></Button>
</div>
}
contentClassName="flex flex-col gap-4 text-sm px-5 pb-5"
>
<div className="flex items-center gap-2">
<span className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
selected.enabled
? "bg-emerald-500/10 text-emerald-600"
: "bg-muted text-muted-foreground"
)}>
{selected.enabled ? "Активен" : "Выключен"}
</span>
</div>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<p className="text-xs text-muted-foreground">{selected.description}</p>
<div className="space-y-2">
@@ -272,15 +269,14 @@ export default function CommunitiesPage() {
Скопировать значение
</Button>
</div>
</CardContent>
</Card>
</OpsPanel>
) : (
<Card className="h-fit">
<CardContent className="flex flex-col items-center justify-center py-12 gap-3 text-center">
<Frame dense className="w-full h-fit">
<FramePanel className="flex flex-col items-center justify-center py-12 gap-3 text-center">
<TagIcon className="size-8 text-muted-foreground/30" />
<p className="text-xs text-muted-foreground">Выберите community<br />для просмотра деталей</p>
</CardContent>
</Card>
</FramePanel>
</Frame>
)}
</div>
</div>
+21 -20
View File
@@ -5,7 +5,9 @@ import { PageHeader } from "@/components/page-header"
import { routerContainers, servers } from "@/lib/data"
import type { RouterContainer } from "@/lib/data"
import { Flag } from "@/components/flag"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
@@ -169,7 +171,8 @@ function ContainerCard({
const cfg = statusConfig(container.status)
return (
<Card className="overflow-hidden">
<Frame dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
<div className="px-4 py-3 border-b flex items-center justify-between gap-2 bg-muted/10">
<div className="flex items-center gap-2 min-w-0">
<span className={cn("size-2 rounded-full shrink-0", cfg.dot)} />
@@ -201,7 +204,7 @@ function ContainerCard({
</DropdownMenu>
</div>
<CardContent className="px-4 py-3 flex flex-col gap-3">
<div className="px-4 py-3 flex flex-col gap-3">
{/* image */}
<div className="flex items-center gap-2">
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" />
@@ -273,8 +276,9 @@ function ContainerCard({
{container.comment && (
<p className="text-xs text-muted-foreground border-t pt-2">{container.comment}</p>
)}
</CardContent>
</Card>
</div>
</FramePanel>
</Frame>
)
}
@@ -323,15 +327,17 @@ export default function ContainersPage() {
{ label: "Stopped", value: stopped, icon: <StopCircleIcon className="size-4 text-muted-foreground" /> },
{ label: "Ошибок", value: errors, icon: <AlertCircleIcon className="size-4 text-red-500" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
<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>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
@@ -397,11 +403,7 @@ export default function ContainersPage() {
)}
{/* RouterOS reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">
RouterOS 7.4+ · /container быстрые команды
</p>
<OpsPanel title="RouterOS 7.4+ · /container — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
@@ -457,8 +459,7 @@ export default function ContainersPage() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
+88 -90
View File
@@ -1,10 +1,12 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
import { usePathname } from "next/navigation"
import Link from "next/link"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { OpsPanel } from "@/components/ops-panel"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { StatusDot } from "@/components/status-dot"
import { StatusBadge } from "@/components/status-badge"
import { Sparkline } from "@/components/sparkline"
@@ -22,7 +24,7 @@ import type { PingProbe, Server, ServerStatus, ServerType } from "@/lib/data"
import type { GreTunnel } from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import { Flag } from "@/components/flag"
import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, DownloadIcon } from "lucide-react"
import { AlertCircleIcon, AlertTriangleIcon, BellIcon, FilterIcon, GitMergeIcon, InfoIcon, DownloadIcon, ServerIcon } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { DataPageCard } from "@/components/data-page-card"
import { DashboardActiveProbesDataGrid } from "@/components/data-grids/dashboard-active-probes-data-grid"
@@ -46,31 +48,45 @@ function makeApiFetch(backendUrl: string) {
}
function StatCard({
label, value, unit, delta, deltaDir, spark, sparkColor,
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 (
<Card className="relative overflow-hidden">
<CardContent className="pt-5 pb-4 px-5">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
<div className="flex items-baseline gap-1.5 mt-1">
<span className="text-3xl font-semibold tracking-tight tabular-nums">{value}</span>
{unit && <span className="text-sm text-muted-foreground">{unit}</span>}
<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>
{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>
)}
{spark && spark.length > 1 && (
{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>
)}
</CardContent>
</Card>
) : null}
</FramePanel>
</Frame>
)
}
@@ -719,6 +735,7 @@ export default function DashboardPage() {
deltaDir={dashboardKpi.servers.deltaDir}
spark={dashboardKpi.servers.spark}
sparkColor={dashboardKpi.servers.sparkColor}
icon={<ServerIcon aria-hidden />}
/>
<StatCard
label="Активные фильтры"
@@ -728,6 +745,7 @@ export default function DashboardPage() {
deltaDir={dashboardKpi.filters.deltaDir}
spark={dashboardKpi.filters.spark}
sparkColor={dashboardKpi.filters.sparkColor}
icon={<FilterIcon aria-hidden />}
/>
<StatCard
label="BGP-префиксы"
@@ -737,6 +755,7 @@ export default function DashboardPage() {
deltaDir={dashboardKpi.bgp.deltaDir}
spark={dashboardKpi.bgp.spark}
sparkColor={dashboardKpi.bgp.sparkColor}
icon={<GitMergeIcon aria-hidden />}
/>
<StatCard
label="Активные алерты"
@@ -746,26 +765,23 @@ export default function DashboardPage() {
deltaDir={dashboardKpi.alerts.deltaDir}
spark={dashboardKpi.alerts.spark}
sparkColor={dashboardKpi.alerts.sparkColor}
icon={<BellIcon aria-hidden />}
/>
</div>
{/* Latency chart + Events */}
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4">
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Задержка до серверов</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5">
{latencyChartBlock.kind === "mock" && latencyChartBlock.subtitle}
{latencyChartBlock.kind === "live" && latencyChartBlock.subtitle}
{latencyChartBlock.kind === "loading" && "Загрузка…"}
{latencyChartBlock.kind === "empty" && "Нет серии RTT для графика"}
</p>
</div>
</div>
</CardHeader>
<CardContent className="pt-0 px-3">
<OpsPanel
title="Задержка до серверов"
description={
latencyChartBlock.kind === "mock" || latencyChartBlock.kind === "live"
? latencyChartBlock.subtitle
: latencyChartBlock.kind === "loading"
? "Загрузка…"
: "Нет серии RTT для графика"
}
contentClassName="px-3 pb-3"
>
{latencyChartBlock.kind === "loading" && (
<div
className="w-full rounded-md bg-muted/50 animate-pulse"
@@ -783,23 +799,20 @@ export default function DashboardPage() {
labels={latencyChartBlock.labels}
/>
)}
</CardContent>
</Card>
</OpsPanel>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Последние события</CardTitle>
<OpsPanel
title="Последние события"
description="Система и BGP-активность"
headerRight={
<Link
href="/alerts"
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "text-xs h-7")}
>
Все
</Link>
</div>
<p className="text-sm text-muted-foreground">Система и BGP-активность</p>
</CardHeader>
<CardContent className="pt-0 px-0">
}
>
<div className="divide-y divide-border">
{eventsLoading && recentEvents.length === 0 && (
<div className="px-5 py-6 text-sm text-muted-foreground">Загрузка событий...</div>
@@ -825,48 +838,41 @@ export default function DashboardPage() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
{/* Bandwidth + Server status */}
<div className="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-4">
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Пропускная способность</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5">Суммарный RX / TX по всем серверам</p>
</div>
<OpsPanel
title="Пропускная способность"
description="Суммарный RX / TX по всем серверам"
headerRight={
<div className="flex items-center gap-3 text-xs">
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-foreground/80 rounded inline-block" />RX 318 Мбит/с</span>
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-[var(--chart-tx)] rounded inline-block" />TX 244 Мбит/с</span>
</div>
</div>
</CardHeader>
<CardContent className="pt-0 px-3">
}
contentClassName="px-3 pb-3"
>
<BandwidthChart rx={traffic.rx} tx={traffic.tx} />
</CardContent>
</Card>
</OpsPanel>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<CardTitle className="text-base">Состояние серверов</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5 truncate" title={serverStatusModel.subtitle}>
{serverStatusModel.subtitle}
</p>
</div>
<OpsPanel
title="Состояние серверов"
description={
<span className="truncate" title={serverStatusModel.subtitle}>
{serverStatusModel.subtitle}
</span>
}
headerRight={
<Link
href="/servers"
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs shrink-0")}
>
Управление
</Link>
</div>
</CardHeader>
<CardContent className="pt-0 px-0">
}
>
<div className="divide-y divide-border">
{serverStatusModel.kind === "loading" && (
<>
@@ -916,8 +922,7 @@ export default function DashboardPage() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
<div className="grid grid-cols-1 gap-4">
@@ -933,28 +938,22 @@ export default function DashboardPage() {
</div>
{/* Ping probes table */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Активные пробы</CardTitle>
<p className={cn(
"text-sm mt-0.5",
probesError && isLive ? "text-destructive" : "text-muted-foreground",
)}
>
{probesSubtitle}
</p>
</div>
<OpsPanel
title="Активные пробы"
description={
<span className={probesError && isLive ? "text-destructive" : undefined}>
{probesSubtitle}
</span>
}
headerRight={
<Link
href="/uptime"
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}
>
Открыть монитор
</Link>
</div>
</CardHeader>
<CardContent className="pt-0 px-0">
}
>
<DataPageCard className="rounded-none border-0 shadow-none">
<DashboardActiveProbesDataGrid
probes={activeProbesTable}
@@ -967,8 +966,7 @@ export default function DashboardPage() {
}
/>
</DataPageCard>
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
+33 -39
View File
@@ -6,7 +6,9 @@ import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import {
Collapsible,
@@ -1032,30 +1034,24 @@ export default function DataCollectionPage() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5 max-w-[1100px] mx-auto w-full">
{!prefsHydrated && (
<Card>
<CardHeader>
<CardTitle className="text-base">Загрузка настроек подключения</CardTitle>
<CardDescription className="text-xs">
Читаем режим данных и адрес API из локальных настроек.
</CardDescription>
</CardHeader>
</Card>
<OpsPanel
title="Загрузка настроек подключения"
description="Читаем режим данных и адрес API из локальных настроек."
>
<div />
</OpsPanel>
)}
{prefsHydrated && !isLive && (
<Card>
<CardHeader>
<CardTitle className="text-base">Нужен live-режим</CardTitle>
<CardDescription className="text-xs">
Планировщик и журнал читаются только с бекенда. Включите «Живые» данные и проверьте URL бекенда в настройках.
</CardDescription>
</CardHeader>
<CardContent className="px-5 pb-5">
<OpsPanel
title="Нужен live-режим"
description="Планировщик и журнал читаются только с бекенда. Включите «Живые» данные и проверьте URL бекенда в настройках."
contentClassName="px-5 pb-5"
>
<Link href="/settings" className={cn(buttonVariants({ variant: "default", size: "sm" }), "h-8")}>
Открыть настройки
</Link>
</CardContent>
</Card>
</OpsPanel>
)}
{isLive && collectorError && (
@@ -1070,16 +1066,18 @@ export default function DataCollectionPage() {
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-xl font-semibold tabular-nums mt-0.5 truncate">{s.value}</p>
<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="min-w-0 flex-1 flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-xl leading-none font-bold tabular-nums truncate">{s.value}</p>
<p className="text-[11px] text-muted-foreground mt-1 leading-snug line-clamp-2">{s.sub}</p>
</div>
<div className="shrink-0 mt-0.5">{s.icon}</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
@@ -1203,14 +1201,10 @@ export default function DataCollectionPage() {
</div>
</DataPageCard>
<Card>
<CardHeader className="border-b border-border flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<CardTitle className="text-base">Журнал прогонов</CardTitle>
<CardDescription className="text-xs">
SQLite `scheduler_runs` до 80 записей; раскройте строку для полей и текста ошибки.
</CardDescription>
</div>
<OpsPanel
title="Журнал прогонов"
description="SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
headerRight={
<div className="flex items-center gap-2 shrink-0">
<label className="text-xs text-muted-foreground whitespace-nowrap" htmlFor="run-filter">
Задача
@@ -1229,8 +1223,9 @@ export default function DataCollectionPage() {
))}
</select>
</div>
</CardHeader>
<CardContent className="px-0 pb-0">
}
contentClassName="px-0 pb-0"
>
{schedulerRuns.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10 px-4">Пока нет прогонов</p>
) : (
@@ -1287,8 +1282,7 @@ export default function DataCollectionPage() {
))}
</div>
)}
</CardContent>
</Card>
</OpsPanel>
</>
)}
</div>
-1
View File
@@ -10,7 +10,6 @@ import {
type RecursiveRouteLite,
} from "@/components/data-grids/filters-data-grid"
import { DataPageCard } from "@/components/data-page-card"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
+29 -41
View File
@@ -12,7 +12,9 @@ import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbarFrame } from "@/components/data-page-toolbar"
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { firewallRules, type FirewallRule } from "@/lib/data"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@@ -36,7 +38,7 @@ import {
CheckIcon, PowerIcon, CheckCircleIcon,
PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon,
CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon,
PackageIcon, SlidersHorizontalIcon,
SlidersHorizontalIcon,
} from "lucide-react"
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -725,7 +727,8 @@ function AddressListsTab({ entries, onAdd }: {
{/* groups */}
<div className="flex flex-col gap-3">
{Object.entries(grouped).map(([listName, listEntries]) => (
<Card key={listName}>
<Frame key={listName} dense className="w-full">
<FramePanel className="p-0">
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/20">
<div className="flex items-center gap-2">
<ListFilterIcon className="size-3.5 text-muted-foreground" />
@@ -765,7 +768,8 @@ function AddressListsTab({ entries, onAdd }: {
</div>
))}
</div>
</Card>
</FramePanel>
</Frame>
))}
</div>
</div>
@@ -1309,14 +1313,7 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
<div className="flex flex-col gap-4">
{/* ── Presets + Scenarios card ─────────────────────────────────────────── */}
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-4">
{/* Built-in traffic presets */}
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2.5">
Пресеты трафика
</p>
<OpsPanel title="Пресеты трафика" contentClassName="px-5 py-4 flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
{SIM_PRESETS.map(p => (
<button key={p.id} type="button" onClick={() => applyPreset(p)}
@@ -1335,7 +1332,6 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
</button>
))}
</div>
</div>
<div className="h-px bg-border" />
@@ -1400,17 +1396,11 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
</div>
)}
</div>
</CardContent>
</Card>
</OpsPanel>
{/* ── Packet editor ───────────────────────────────────────────────────── */}
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
<PackageIcon className="size-3.5" />Параметры пакета
</p>
<OpsPanel title="Параметры пакета" contentClassName="px-5 py-4 flex flex-col gap-4">
<div className="flex items-center justify-end flex-wrap gap-2">
<div className="flex items-center gap-3">
<span className={cn(
"text-[11px] font-mono px-2 py-0.5 rounded border",
@@ -1539,8 +1529,7 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
)}
</div>
</div>
</CardContent>
</Card>
</OpsPanel>
{/* ── Verdict banner ──────────────────────────────────────────────────── */}
{verdict && simState === "done" && verdictColors && (
@@ -1568,7 +1557,8 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
{/* ── Trace list ──────────────────────────────────────────────────────── */}
{steps.length > 0 && (
<Card>
<Frame dense className="w-full">
<FramePanel className="p-0">
<div className="px-5 py-3 border-b flex items-center justify-between">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Трассировка · цепочка: <span className="text-foreground normal-case font-mono">{packet.chain}</span>
@@ -1608,7 +1598,8 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
)
})}
</div>
</Card>
</FramePanel>
</Frame>
)}
{/* ── Empty state ─────────────────────────────────────────────────────── */}
@@ -1745,15 +1736,17 @@ export default function FirewallPage() {
{ label: "Блокирующих", value: dropRules, icon: <ShieldOffIcon className="size-4 text-red-500" /> },
{ label: "Срабатываний", value: fmtHits(totalHits), icon: <ListFilterIcon className="size-4 text-sky-400" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
<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>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
@@ -1829,11 +1822,7 @@ export default function FirewallPage() {
)}
{/* RouterOS reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">
RouterOS 7.20+ · /ip firewall + /ipv6 firewall цепочки и новые матчеры
</p>
<OpsPanel title="RouterOS 7.20+ · /ip firewall + /ipv6 firewall — цепочки и новые матчеры" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 text-xs">
{[
{
@@ -1874,8 +1863,7 @@ export default function FirewallPage() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
+15 -14
View File
@@ -13,7 +13,9 @@ import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@@ -430,15 +432,17 @@ export default function GrePage() {
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
{ label: "IP-пулов", value: displayPools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
<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>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
@@ -519,9 +523,7 @@ export default function GrePage() {
)}
{/* RouterOS reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">RouterOS 7.20+ параметры GRE-интерфейса</p>
<OpsPanel title="RouterOS 7.20+ — параметры GRE-интерфейса" contentClassName="px-5 py-4">
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-xs font-mono">
{[
["/interface gre add", ""],
@@ -544,8 +546,7 @@ export default function GrePage() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
+14 -3
View File
@@ -1,3 +1,4 @@
import type { CSSProperties, ReactNode } from "react"
import { AppSidebar } from "@/components/app-sidebar"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { CommandPalette } from "@/components/command-palette"
@@ -5,13 +6,23 @@ import { ReleaseNotesModal } from "@/components/release-notes-modal"
import { DataSourceProvider } from "@/lib/data-source"
import { EvoBGPProvider } from "@/lib/evobgp-context"
export default function MainLayout({ children }: { children: React.ReactNode }) {
const SKIP_TO_CONTENT_CLASS =
"bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2"
export default function MainLayout({ children }: { children: ReactNode }) {
return (
<DataSourceProvider>
<EvoBGPProvider>
<SidebarProvider>
<SidebarProvider
style={{ "--sidebar-width": "240px" } as CSSProperties}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<AppSidebar />
<SidebarInset className="h-svh overflow-hidden">{children}</SidebarInset>
<SidebarInset id="main-content" className="h-svh overflow-hidden">
{children}
</SidebarInset>
<CommandPalette />
<ReleaseNotesModal />
</SidebarProvider>
+23 -21
View File
@@ -6,7 +6,7 @@ import { DataPageCard } from "@/components/data-page-card"
import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-data-grid"
import { OspfRoutesDataGrid, routeTypeClass } from "@/components/data-grids/ospf-routes-data-grid"
import { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { toast } from "sonner"
@@ -809,7 +809,8 @@ function InterfacesTab({
{grouped.map(router => {
const totalIfaces = router.areas.reduce((s, a) => s + a.items.length, 0)
return (
<Card key={router.routerKey} className="overflow-hidden gap-0 py-0">
<Frame key={router.routerKey} dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
<div className="flex items-center gap-3 px-4 py-3 border-b">
<NetworkIcon className="size-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
@@ -873,7 +874,8 @@ function InterfacesTab({
</div>
</div>
))}
</Card>
</FramePanel>
</Frame>
)
})}
</div>
@@ -923,12 +925,12 @@ function NeighborsTab({
{ 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" },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className={cn("text-2xl font-semibold tabular-nums", s.color)}>{s.value}</p>
</CardContent>
</Card>
<Frame key={s.label} className="h-full">
<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>
</FramePanel>
</Frame>
))}
</div>
@@ -1047,12 +1049,12 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
{ label: "Down / Admin", value: downCount, color: downCount > 0 ? "text-[var(--status-offline-fg)]" : "text-muted-foreground" },
{ label: "Init / другие", value: initCount, color: initCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className={cn("text-2xl font-semibold tabular-nums", s.color)}>{s.value}</p>
</CardContent>
</Card>
<Frame key={s.label} className="h-full">
<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>
</FramePanel>
</Frame>
))}
</div>
@@ -1350,12 +1352,12 @@ export default function OspfPage() {
{ label: "Интерфейсов", value: totalInterfaces },
{ label: "Зон (Area)", value: totalAreas },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums">{s.value}</p>
</CardContent>
</Card>
<Frame key={s.label} className="h-full">
<FramePanel className="flex 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>
</FramePanel>
</Frame>
))}
</div>
+12 -9
View File
@@ -3,7 +3,8 @@
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import {
ProbesScheduleDataGrid,
@@ -516,7 +517,8 @@ function ScheduleTab({
/>
</DataPageCard>
{showAdd ? (
<Card className="overflow-hidden">
<Frame dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
<div className="px-4 py-3 border-b flex items-center gap-2 text-sm font-medium">
<PlusIcon className="size-4 text-muted-foreground" />Новое правило
</div>
@@ -552,7 +554,8 @@ function ScheduleTab({
</Button>
<Button variant="outline" size="sm" onClick={() => setShowAdd(false)}>Отмена</Button>
</div>
</Card>
</FramePanel>
</Frame>
) : (
<Button variant="outline" size="sm" className="w-fit" onClick={() => setShowAdd(true)}>
<PlusIcon className="size-4" />Добавить правило
@@ -918,8 +921,7 @@ export default function ProbesPage() {
)}
{/* ── tool selector + config ── */}
<Card>
<CardContent className="pt-4 pb-4 px-4 flex flex-col gap-4">
<OpsPanel contentClassName="pt-4 pb-4 px-4 flex flex-col gap-4">
{/* tool chips */}
<div className="flex gap-2 flex-wrap">
@@ -1062,8 +1064,7 @@ export default function ProbesPage() {
<CopyIcon className="size-3" />
</button>
</div>
</CardContent>
</Card>
</OpsPanel>
{/* ── tabs ── */}
<div>
@@ -1102,7 +1103,8 @@ export default function ProbesPage() {
{tests.map(test => {
const { Icon, color, label } = TOOL_META[test.tool]
return (
<Card key={test.id} className="overflow-hidden">
<Frame key={test.id} dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
{/* header */}
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b bg-muted/20">
<div className="flex items-center gap-2 min-w-0">
@@ -1149,7 +1151,8 @@ export default function ProbesPage() {
<div className="p-3">
<TerminalOutput test={test} />
</div>
</Card>
</FramePanel>
</Frame>
)
})}
</div>
+6 -4
View File
@@ -9,7 +9,7 @@ import {
} from "@/components/data-grids/recursive-routes-data-grid"
import { FormField, SectionTitle } from "@/components/form-kit"
import { DataPageCard } from "@/components/data-page-card"
import { Card } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
@@ -651,9 +651,11 @@ export default function RecursiveRoutesPage() {
<div className="flex-1 overflow-y-auto p-6">
{!isLive ? (
<Card className="p-6 text-sm text-muted-foreground">
Раздел работает в режиме &quot;Живые данные&quot;. Переключи источник данных в настройках.
</Card>
<Frame dense className="w-full">
<FramePanel className="p-6 text-sm text-muted-foreground">
Раздел работает в режиме &quot;Живые данные&quot;. Переключи источник данных в настройках.
</FramePanel>
</Frame>
) : (
<DataPageCard>
{currentServer && (
+18 -22
View File
@@ -1,6 +1,6 @@
import Link from "next/link"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { OpsPanel } from "@/components/ops-panel"
import { buttonVariants } from "@/components/ui/button"
import { formatAppVersionLabel, getAppVersion, getReleaseUrl } from "@/lib/app-version"
import {
@@ -52,12 +52,11 @@ export default function ReleasesPage() {
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-[960px] mx-auto space-y-6">
<Card>
<CardHeader>
<CardTitle>Текущая версия</CardTitle>
<CardDescription>Сборка MikrotikManager, опубликованная через CI/CD.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<OpsPanel
title="Текущая версия"
description="Сборка MikrotikManager, опубликованная через CI/CD."
contentClassName="flex flex-col gap-3 px-5 pb-5"
>
<div className="flex flex-wrap items-center gap-3">
<span className="text-2xl font-semibold tracking-tight">{formatAppVersionLabel(version)}</span>
<span className="text-sm text-muted-foreground">{formatPublishedAt(manifest.publishedAt, isProdBuild)}</span>
@@ -65,23 +64,21 @@ export default function ReleasesPage() {
<p className="text-sm text-muted-foreground">
Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits.
</p>
</CardContent>
</Card>
</OpsPanel>
<Card>
<CardHeader>
<CardTitle>Сводка коммитов</CardTitle>
<CardDescription>
{commits.length > 0
? `Последние ${RELEASE_COMMIT_PREVIEW_LIMIT} коммитов в сборке ${formatAppVersionLabel(manifest.version)}.`
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."}
</CardDescription>
</CardHeader>
<CardContent>
<OpsPanel
title="Сводка коммитов"
description={
commits.length > 0
? `Последние ${RELEASE_COMMIT_PREVIEW_LIMIT} коммитов в сборке ${formatAppVersionLabel(manifest.version)}.`
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."
}
contentClassName="px-5 pb-5"
>
{commits.length === 0 ? (
<p className="text-sm text-muted-foreground">Коммитов для отображения пока нет.</p>
) : (
<ul className="space-y-3">
<ul className="flex flex-col gap-3">
{commits.map((commit) => (
<li key={commit.sha} className="rounded-md border px-3 py-2">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
@@ -94,8 +91,7 @@ export default function ReleasesPage() {
))}
</ul>
)}
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
</div>
+43 -57
View File
@@ -9,7 +9,9 @@ import { RouteOptimizerFullRoutesDataGrid } from "@/components/data-grids/route-
import { RouteOptimizerCommRecsDataGrid } from "@/components/data-grids/route-optimizer-comm-recs-data-grid"
import { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid"
import { FormToggle } from "@/components/form-kit"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Flag } from "@/components/flag"
@@ -41,7 +43,7 @@ import {
RefreshCwIcon, AlertCircleIcon, ArrowRightIcon,
SettingsIcon, ChevronDownIcon, ChevronUpIcon, PinIcon,
ZapIcon, PlayIcon, CheckCircleIcon, NetworkIcon, WifiIcon,
MonitorIcon, ServerIcon, GitMergeIcon, ShieldIcon, LayersIcon,
MonitorIcon, ServerIcon,
InfoIcon,
} from "lucide-react"
@@ -290,7 +292,8 @@ function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying,
const switchCount = commRecs.filter(r => r.shouldSwitch && !pinned.has(`${home.id}::${r.community}`)).length
return (
<Card className="gap-0 py-0 overflow-hidden">
<Frame dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b flex-wrap">
<MonitorIcon className="size-4 text-muted-foreground shrink-0" />
@@ -366,7 +369,8 @@ function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying,
onPin={onPin} onApply={onApply}
/>
)}
</Card>
</FramePanel>
</Frame>
)
}
@@ -761,16 +765,18 @@ export default function RouteOptimizerPage() {
{/* Stats chips */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{statsChips.map((s) => (
<Card key={s.label}>
<CardContent className="px-4 py-3 flex items-start justify-between">
<div>
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className="text-xl font-semibold tabular-nums mt-0.5">{s.value}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">{s.sub}</p>
<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-xl leading-none font-bold tabular-nums">{s.value}</p>
<p className="text-[10px] text-muted-foreground">{s.sub}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
@@ -787,7 +793,8 @@ export default function RouteOptimizerPage() {
)}
{/* Settings */}
<Card className="gap-0 py-0">
<Frame dense className="w-full">
<FramePanel className="p-0">
<div
className={cn("flex items-center gap-3 px-4 py-3 cursor-pointer select-none", showSettings && "border-b")}
onClick={() => setShowSettings(v => !v)}
@@ -801,7 +808,7 @@ export default function RouteOptimizerPage() {
</div>
{showSettings && (
<CardContent className="py-4 px-5">
<div className="py-4 px-5">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="flex flex-col gap-3">
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Пороги переключения</p>
@@ -866,20 +873,17 @@ export default function RouteOptimizerPage() {
<Link href="/settings#route-ai" className="text-primary underline-offset-2 hover:underline">Настройки Route AI</Link>
.
</p>
</CardContent>
</div>
)}
</Card>
</FramePanel>
</Frame>
{/* OSPF optimization from Route Optimizer */}
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-2.5">
<div className="flex items-center gap-2 text-sm">
<NetworkIcon className="size-4 text-sky-400 shrink-0" />
<span className="font-semibold">OSPF</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">
Route AI weight: ping {settings.pingWeight}%
</span>
</div>
<OpsPanel
title="OSPF"
description={`Route AI weight: ping ${settings.pingWeight}%`}
contentClassName="px-5 py-4 flex flex-col gap-2.5"
>
{!useLiveData && (
<p className="text-xs text-muted-foreground">
@@ -960,20 +964,16 @@ export default function RouteOptimizerPage() {
)}
</>
)}
</CardContent>
</Card>
</OpsPanel>
{/* ─── ECMP / RPF / VRF section ──────────────────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* ECMP Card */}
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-4">
<div className="flex items-center gap-2">
<GitMergeIcon className="size-4 text-violet-400" />
<span className="font-semibold text-sm">ECMP</span>
<span className="text-[10px] text-muted-foreground ml-1">Equal-Cost Multi-Path</span>
<div className="ml-auto">
<OpsPanel
title="ECMP"
description="Equal-Cost Multi-Path"
headerRight={
<button type="button" role="switch" aria-checked={ecmpEnabled}
onClick={() => setEcmpEnabled(v => !v)}
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
@@ -981,8 +981,9 @@ export default function RouteOptimizerPage() {
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
ecmpEnabled ? "translate-x-4" : "translate-x-0")} />
</button>
</div>
</div>
}
contentClassName="px-5 py-4 flex flex-col gap-4"
>
<div className={cn("flex flex-col gap-3 transition-opacity", !ecmpEnabled && "opacity-40 pointer-events-none")}>
<div className="flex flex-col gap-1.5">
@@ -1013,17 +1014,10 @@ export default function RouteOptimizerPage() {
<span>RouterOS 7.x: <code className="font-mono">/routing/rule add ecmp=yes</code></span>
</div>
</div>
</CardContent>
</Card>
</OpsPanel>
{/* RPF Card */}
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-4">
<div className="flex items-center gap-2">
<ShieldIcon className="size-4 text-amber-400" />
<span className="font-semibold text-sm">RPF</span>
<span className="text-[10px] text-muted-foreground ml-1">Reverse Path Forwarding</span>
</div>
<OpsPanel title="RPF" description="Reverse Path Forwarding" contentClassName="px-5 py-4 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="text-xs text-muted-foreground">Режим проверки источника</span>
@@ -1057,17 +1051,10 @@ export default function RouteOptimizerPage() {
<span><code className="font-mono">/ip settings set rp-filter={rpfMode}</code></span>
</div>
)}
</CardContent>
</Card>
</OpsPanel>
{/* VRF Card */}
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-4">
<div className="flex items-center gap-2">
<LayersIcon className="size-4 text-sky-400" />
<span className="font-semibold text-sm">VRF</span>
<span className="text-[10px] text-muted-foreground ml-1">Virtual Routing</span>
</div>
<OpsPanel title="VRF" description="Virtual Routing" contentClassName="px-5 py-4 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">Контекст оптимизации маршрутов</span>
@@ -1099,8 +1086,7 @@ export default function RouteOptimizerPage() {
<InfoIcon className="size-3 shrink-0 mt-0.5" />
<span>Оптимизатор работает в VRF <code className="font-mono">{selectedVrf}</code></span>
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
+16 -13
View File
@@ -27,7 +27,8 @@ import {
import { useDataSource } from "@/lib/data-source"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -493,20 +494,22 @@ export default function ServersPage() {
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ label: "Всего серверов", value: counts.all, icon: <ServerIcon className="size-4 text-muted-foreground" /> },
{ label: "Онлайн", value: counts.online, icon: <CheckCircleIcon className="size-4 text-emerald-500" /> },
{ label: "JH + Exit Node", value: counts["jump-host"] + counts["exit-node"], icon: <NetworkIcon className="size-4 text-violet-400" /> },
{ label: "Home Router", value: counts["home-router"],icon: <HomeIcon className="size-4 text-emerald-400" /> },
{ 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)]" },
{ label: "JH + Exit Node", value: counts["jump-host"] + counts["exit-node"], icon: <NetworkIcon className="size-4" />, iconClass: "text-muted-foreground" },
{ label: "Home Router", value: counts["home-router"],icon: <HomeIcon className="size-4" />, iconClass: "text-muted-foreground" },
].map(s => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
<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={cn("size-10.5", s.iconClass)}>
{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>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
+70 -101
View File
@@ -5,9 +5,8 @@ import Link from "next/link"
import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle } from "@/components/form-kit"
import { FileImportDialog } from "@/components/file-import-dialog"
import {
Card, CardContent, CardHeader, CardTitle, CardDescription,
} from "@/components/ui/card"
import { OpsPanel } from "@/components/ops-panel"
import { Frame, FramePanel } from "@/components/reui/frame"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useDataSource } from "@/lib/data-source"
@@ -938,16 +937,15 @@ export default function SettingsPage() {
<div className="space-y-4">
{/* ── Источник данных ── */}
<Card>
<CardHeader>
<CardTitle className="text-base">Источник данных</CardTitle>
<CardDescription className="text-xs">
{mockModeAvailable
<OpsPanel
title="Источник данных"
description={
mockModeAvailable
? "Переключите между живыми данными от бекенда и суррогатными моками из lib/data.ts"
: "В продакшен-образе доступны только живые данные от бекенда"}
</CardDescription>
</CardHeader>
<CardContent className="divide-y px-5">
: "В продакшен-образе доступны только живые данные от бекенда"
}
contentClassName="divide-y px-5"
>
{/* mode toggle */}
{mockModeAvailable ? (
@@ -1042,13 +1040,10 @@ export default function SettingsPage() {
<span>Бекенд недоступен данные будут отображаться из кеша или моков. Запустите бекенд: <code className="font-mono bg-muted px-1 rounded">cd backend && npm run dev</code></span>
</div>
)}
</CardContent>
</Card>
</OpsPanel>
{/* ── Основные настройки ── */}
<Card>
<CardHeader><CardTitle className="text-base">Основные настройки</CardTitle></CardHeader>
<CardContent className="divide-y px-5">
<OpsPanel title="Основные настройки" contentClassName="divide-y px-5">
<SettingRow label="Язык интерфейса">
<select className="text-sm bg-background text-foreground border border-input rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
value={lang} onChange={e => setLang(e.target.value)}>
@@ -1083,18 +1078,13 @@ export default function SettingsPage() {
description="Максимальное время ожидания при probe-тестах">
<Input className="w-20 h-8 text-sm" value={probeTimeout} onChange={e => setProbeTimeout(e.target.value)} />
</SettingRow>
</CardContent>
</Card>
</OpsPanel>
<Card>
<CardHeader>
<CardTitle className="text-base">База данных приложения</CardTitle>
<CardDescription className="text-xs">
Резервная копия SQLite бекенда: серверы, мониторинг, оповещения, EvoBGP. На время операции планировщик
сбора данных приостанавливается.
</CardDescription>
</CardHeader>
<CardContent className="divide-y px-5">
<OpsPanel
title="База данных приложения"
description="Резервная копия SQLite бекенда: серверы, мониторинг, оповещения, EvoBGP. На время операции планировщик сбора данных приостанавливается."
contentClassName="divide-y px-5"
>
{!systemDbAvailable && (
<div className="flex items-start gap-2 py-3 text-xs text-amber-600 dark:text-amber-400">
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
@@ -1136,18 +1126,15 @@ export default function SettingsPage() {
)}
</div>
</SettingRow>
</CardContent>
</Card>
</OpsPanel>
<Card id="route-ai" className="scroll-mt-4">
<CardHeader>
<CardTitle className="text-base">Route AI</CardTitle>
<CardDescription className="text-xs">
Параметры оптимизации маршрутов по умолчанию (тот же набор, что на странице «Оптимизатор маршрутов»). Пороги и
веса настраиваются в инструменте, не через отдельный API.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 px-5 pb-5">
<OpsPanel
id="route-ai"
className="scroll-mt-4"
title="Route AI"
description="Параметры оптимизации маршрутов по умолчанию (тот же набор, что на странице «Оптимизатор маршрутов»). Пороги и веса настраиваются в инструменте, не через отдельный API."
contentClassName="flex flex-col gap-4 px-5 pb-5"
>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-2 text-xs">
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
<dt className="text-muted-foreground">Мин. выигрыш (переключение)</dt>
@@ -1176,8 +1163,7 @@ export default function SettingsPage() {
>
Открыть оптимизатор маршрутов
</Link>
</CardContent>
</Card>
</OpsPanel>
</div>
)
@@ -1186,20 +1172,20 @@ export default function SettingsPage() {
if (section === "EvoBGP") return (
<div className="space-y-4">
{(mode !== "live" || backendStatus !== true) && (
<Card>
<CardContent className="pt-4 pb-4 px-4">
<Frame dense className="w-full">
<FramePanel className="px-4 py-4">
<p className="text-sm font-medium">Интеграция доступна в live-режиме</p>
<p className="text-xs text-muted-foreground mt-1">
Включите «Живые» данные и убедитесь, что локальный бекенд доступен (раздел «Общие»).
</p>
</CardContent>
</Card>
</FramePanel>
</Frame>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">EvoBGP API</CardTitle>
<CardDescription className="text-xs">
<OpsPanel
title="EvoBGP API"
description={
<>
Control plane EvoBGP: Bearer-ключ и роль viewer+ см.{" "}
<a
className="underline underline-offset-2"
@@ -1213,9 +1199,10 @@ export default function SettingsPage() {
<code className="font-mono bg-muted px-1 rounded">evobgp_settings</code>
). Каталог {" "}
<code className="font-mono bg-muted px-1 rounded">GET /v1/router-lists/catalog</code> через прокси.
</CardDescription>
</CardHeader>
<CardContent className="divide-y px-5 space-y-4 pb-5">
</>
}
contentClassName="divide-y px-5 flex flex-col gap-4 pb-5"
>
<SettingRow
label="Базовый URL API"
description="Например http://control.example:8080 — без суффикса /v1"
@@ -1361,17 +1348,14 @@ export default function SettingsPage() {
{evo.error && (
<p className="text-xs text-destructive">{evo.error}</p>
)}
</CardContent>
</Card>
</OpsPanel>
</div>
)
// ── Уведомления ──
if (section === "Уведомления") return (
<div className="space-y-4">
<Card>
<CardHeader><CardTitle className="text-base">Каналы уведомлений</CardTitle></CardHeader>
<CardContent className="divide-y px-5">
<OpsPanel title="Каналы уведомлений" contentClassName="divide-y px-5">
<SettingRow label="Email" description="Отправка уведомлений на [email protected]">
<FormToggle checked={notifEmail} onChange={setNotifEmail} />
</SettingRow>
@@ -1384,11 +1368,8 @@ export default function SettingsPage() {
<FormToggle checked={notifWh} onChange={setNotifWh} />
</SettingRow>
{notifWh && <div className="py-3"><Input className="text-sm h-8 font-mono" defaultValue="https://hooks.example.com/routerlists" /></div>}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-base">Триггеры</CardTitle></CardHeader>
<CardContent className="divide-y px-5">
</OpsPanel>
<OpsPanel title="Триггеры" contentClassName="divide-y px-5">
<SettingRow label="Деградация узла" description="Потери пакетов > 5% или RTT > 100мс">
<FormToggle checked={notifDegr} onChange={setNotifDegr} />
</SettingRow>
@@ -1401,8 +1382,7 @@ export default function SettingsPage() {
<SettingRow label="Просроченный бэкап" description="Если последний бэкап старше 2 дней">
<FormToggle checked={notifBackup} onChange={setNotifBackup} />
</SettingRow>
</CardContent>
</Card>
</OpsPanel>
</div>
)
@@ -1419,7 +1399,8 @@ export default function SettingsPage() {
</Button>
</div>
<Card className="overflow-hidden gap-0 py-0">
<Frame dense className="w-full">
<FramePanel className="overflow-hidden p-0">
{/* table header */}
<div className="grid grid-cols-[1fr_120px_100px_80px_auto] items-center gap-3 px-4 py-2 border-b bg-muted/30 text-[11px] font-medium text-muted-foreground">
<span>Пользователь</span>
@@ -1475,7 +1456,8 @@ export default function SettingsPage() {
)
})}
</div>
</Card>
</FramePanel>
</Frame>
{/* access summary */}
<DataPageCard>
@@ -1500,8 +1482,8 @@ export default function SettingsPage() {
</div>
<div className="space-y-3">
{apiKeys.map(k => (
<Card key={k.id}>
<CardContent className="pt-4 pb-3 px-4">
<Frame key={k.id} dense className="w-full">
<FramePanel className="pt-4 pb-3 px-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="text-sm font-medium">{k.name}</p>
@@ -1529,16 +1511,15 @@ export default function SettingsPage() {
</div>
</div>
</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
<Card>
<CardHeader>
<CardTitle className="text-sm">Документация API</CardTitle>
<CardDescription className="text-xs">Base URL: https://api.routerlists.io/v1</CardDescription>
</CardHeader>
<CardContent className="px-4 pb-4">
<OpsPanel
title="Документация API"
description="Base URL: https://api.routerlists.io/v1"
contentClassName="px-4 pb-4"
>
<div className="grid grid-cols-2 gap-2 text-xs">
{[
["GET", "/servers", "Список серверов"],
@@ -1556,17 +1537,14 @@ export default function SettingsPage() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
)
// ── Безопасность ──
if (section === "Безопасность") return (
<div className="space-y-4">
<Card>
<CardHeader><CardTitle className="text-base">Аутентификация</CardTitle></CardHeader>
<CardContent className="divide-y px-5">
<OpsPanel title="Аутентификация" contentClassName="divide-y px-5">
<SettingRow label="Двухфакторная аутентификация (MFA)"
description="TOTP / Authenticator app для всех администраторов">
<FormToggle checked={mfa} onChange={setMfa} />
@@ -1574,30 +1552,22 @@ export default function SettingsPage() {
<SettingRow label="Тайм-аут сессии (мин)" description="Автоматический выход при бездействии">
<Input className="w-20 h-8 text-sm" value={sessMin} onChange={e => setSessMin(e.target.value)} />
</SettingRow>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Allowlist IP-адресов</CardTitle>
<CardDescription className="text-xs">Доступ разрешён только с этих сетей. Одна запись на строку.</CardDescription>
</CardHeader>
<CardContent className="px-4 pb-4">
</OpsPanel>
<OpsPanel
title="Allowlist IP-адресов"
description="Доступ разрешён только с этих сетей. Одна запись на строку."
contentClassName="px-4 pb-4"
>
<textarea value={ipAllow} onChange={e => setIpAllow(e.target.value)} rows={4}
className="w-full rounded-md border bg-muted px-3 py-2 text-xs font-mono resize-none outline-none focus:ring-1 focus:ring-ring" />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-base">Аудит</CardTitle></CardHeader>
<CardContent className="divide-y px-5">
</OpsPanel>
<OpsPanel title="Аудит" contentClassName="divide-y px-5">
<SettingRow label="Расширенный журнал аудита"
description="Записывать все изменения конфигурации с указанием пользователя и IP">
<FormToggle checked={auditLog} onChange={setAuditLog} />
</SettingRow>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-base text-destructive">Опасная зона</CardTitle></CardHeader>
<CardContent className="px-4 pb-4">
</OpsPanel>
<OpsPanel title={<span className="text-destructive">Опасная зона</span>} contentClassName="px-4 pb-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Сбросить все настройки</p>
@@ -1607,8 +1577,7 @@ export default function SettingsPage() {
Сбросить
</Button>
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
)
}
+17 -11
View File
@@ -2,7 +2,8 @@
import { useState, useMemo, useEffect, useCallback } from "react"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { Button } from "@/components/ui/button"
import { Sparkline } from "@/components/sparkline"
import { StatusDot } from "@/components/status-dot"
@@ -972,12 +973,17 @@ export default function TrafficPage() {
{ icon: <TrendingUpIcon className="size-3.5 text-amber-500" />, label: "Пик RX", value: fmtMbps(peakRx) },
{ icon: <TrendingUpIcon className="size-3.5 text-amber-500" />, label: "Пик TX", value: fmtMbps(peakTx) },
].map(({ icon, label, value }) => (
<Card key={label} className="overflow-hidden">
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground mb-1 flex items-center gap-1.5">{icon}{label}</p>
<p className="text-2xl font-semibold tabular-nums tracking-tight">{value}</p>
</CardContent>
</Card>
<Frame key={label} className="h-full overflow-hidden">
<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}
</IconTile>
<div className="flex min-w-0 flex-1 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 tracking-tight">{value}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
@@ -1034,8 +1040,8 @@ export default function TrafficPage() {
</div>
{/* ── detail panel ── */}
<Card className="flex flex-col">
<CardContent className="flex-1 px-5 pb-5 pt-5">
<Frame dense className="w-full flex flex-col">
<FramePanel className="flex-1 px-5 pb-5 pt-5">
{isLive && effectiveMode === "servers" && (
<div className="mb-3 pb-3 border-b">
<div className="flex items-center gap-2 flex-wrap">
@@ -1088,8 +1094,8 @@ export default function TrafficPage() {
{effectiveMode === "servers" && (liveDetailServer ?? selServer) && <ServerDetail sel={(liveDetailServer ?? selServer)!} range={range} setRange={setRange} />}
{effectiveMode === "users" && <UserDetail sel={selUser} range={range} setRange={setRange} />}
{effectiveMode === "gre" && <GreDetail sel={selGre} range={range} setRange={setRange} />}
</CardContent>
</Card>
</FramePanel>
</Frame>
</div>
</div>
+27 -20
View File
@@ -3,7 +3,8 @@
import { useState, useMemo, useEffect, useRef, useCallback } from "react"
import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
@@ -970,15 +971,17 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
{ icon: <AlertCircleIcon className="size-4" />, label: String(highRam), sub: "RAM > 85%", color: highRam > 0 ? "text-red-500" : "text-muted-foreground" },
{ icon: <AlertCircleIcon className="size-4" />, label: String(highHdd), sub: "Диск > 85%", color: highHdd > 0 ? "text-amber-500" : "text-muted-foreground" },
].map(kpi => (
<Card key={kpi.sub}>
<CardContent className="px-4 py-3 flex items-start gap-3">
<div className={cn("mt-0.5 shrink-0", kpi.color)}>{kpi.icon}</div>
<div className="min-w-0">
<p className={cn("text-xl font-semibold tabular-nums leading-tight", kpi.color)}>{kpi.label}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">{kpi.sub}</p>
<Frame key={kpi.sub} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className={cn("size-10.5", kpi.color)}>
{kpi.icon}
</IconTile>
<div className="min-w-0 flex flex-col gap-0.5">
<p className={cn("text-xl leading-none font-bold tabular-nums", kpi.color)}>{kpi.label}</p>
<p className="text-[11px] text-muted-foreground">{kpi.sub}</p>
</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
@@ -2026,11 +2029,11 @@ export default function UptimePage() {
{ 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)]" },
].map(k => (
<Card key={k.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{k.label}</p>
<div className="flex items-baseline gap-1 mt-0.5">
<span className={cn("text-2xl font-semibold tabular-nums", k.color)}>{k.value}</span>
<Frame key={k.label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{k.label}</p>
<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>}
</div>
{runningCnt > 0 && k.label === "Тестов выполнено" && (
@@ -2038,8 +2041,8 @@ export default function UptimePage() {
<RefreshCwIcon className="size-2.5 animate-spin" />{runningCnt} выполняется
</p>
)}
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
)
@@ -2077,7 +2080,8 @@ export default function UptimePage() {
{speedGrouped.map(({ server, probes: srvProbes }) => {
const isCollapsed = speedCollapsed.has(server.id)
return (
<Card key={server.id} className="overflow-hidden py-0 gap-0">
<Frame key={server.id} dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
{/* server header */}
<button
onClick={() => toggleSpeedCollapse(server.id)}
@@ -2245,7 +2249,8 @@ export default function UptimePage() {
})}
</div>
)}
</Card>
</FramePanel>
</Frame>
)
})}
@@ -2365,7 +2370,8 @@ export default function UptimePage() {
const warnCount = allServerProbes.filter(p => p.status === "warn").length
return (
<Card key={srv.id} className="overflow-hidden py-0 gap-0">
<Frame key={srv.id} dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
{/* server header */}
<button onClick={() => toggleCollapse(srv.id)}
@@ -2592,7 +2598,8 @@ export default function UptimePage() {
</div>
</>
)}
</Card>
</FramePanel>
</Frame>
)
})}
</div>
+15 -16
View File
@@ -4,7 +4,9 @@ import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { vxlanTunnels, servers } from "@/lib/data"
import type { VxlanTunnel } from "@/lib/data"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
@@ -163,15 +165,17 @@ export default function VxlanPage() {
{ label: "Уникальных VNI", value: vnis, icon: <LayersIcon className="size-4 text-sky-400" /> },
{ label: "Серверов", value: new Set(vxlanTunnels.map((t) => t.serverId)).size, icon: <NetworkIcon className="size-4 text-violet-400" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
<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>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
@@ -203,11 +207,7 @@ export default function VxlanPage() {
</DataPageCard>
{/* Reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">
RouterOS 7 · /interface/vxlan быстрые команды
</p>
<OpsPanel title="RouterOS 7 · /interface/vxlan — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
@@ -257,8 +257,7 @@ export default function VxlanPage() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
</div>
+15 -16
View File
@@ -11,7 +11,9 @@ import {
type WgIfaceWithServer,
} from "@/components/data-grids/wireguard-data-grid"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel"
import { cn } from "@/lib/utils"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
@@ -176,15 +178,17 @@ export default function WireGuardPage() {
{ label: "Всего пиров", value: totalPeers, icon: <UsersIcon className="size-4 text-sky-400" /> },
{ label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: <KeyRoundIcon className="size-4 text-violet-400" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
<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>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
))}
</div>
@@ -215,11 +219,7 @@ export default function WireGuardPage() {
</DataPageCard>
{/* RouterOS reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">
RouterOS 7 · /interface wireguard быстрые команды
</p>
<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">
{[
{
@@ -262,8 +262,7 @@ export default function WireGuardPage() {
</div>
))}
</div>
</CardContent>
</Card>
</OpsPanel>
</div>
</div>