Compare commits

...
10 Commits
Author SHA1 Message Date
Denozordec d3a2d38b37 fix(ui): заменить таблицы на карточки данных и улучшить функциональность поиска
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 1m50s
Docker images / updater-image (push) Successful in 44s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
2026-06-30 22:22:51 +07:00
Denozordec a1a9124f3d fix(config): добавить новые зависимости и обновить конфигурацию компонентов
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m37s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
2026-06-30 21:30:40 +07:00
Denozordec 009011a917 chore(docs): обновить инструкции по локальному запуску проекта
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 3m42s
Docker images / frontend-image (push) Successful in 2m40s
Docker images / updater-image (push) Successful in 53s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
2026-06-30 20:07:20 +07:00
Denozordec 399871f4f9 fix(config): улучшить обработку URL для бэкенда и увеличить таймаут запросов
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m36s
Docker images / frontend-image (push) Successful in 1m39s
Docker images / updater-image (push) Successful in 50s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
2026-05-12 23:19:51 +07:00
Denozordec 158fc36294 fix(api): увеличить лимит размера тела запроса и улучшить обработку URL для API
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m36s
Docker images / frontend-image (push) Successful in 1m40s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
2026-05-12 23:11:23 +07:00
Denozordec 7dc4836c71 fix(dashboard): улучшить логику загрузки данных на странице панели управления
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 1m47s
Docker images / updater-image (push) Successful in 54s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
2026-05-12 22:47:30 +07:00
Denozordec efc3812e12 fix(updater): обновить URL для проверки состояния сервиса 2026-05-12 22:46:16 +07:00
Denozordec 8d5fd84962 fix(data-collection): добавить возможность отключения переключателей и улучшить обработку настроек
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m31s
Docker images / frontend-image (push) Successful in 1m41s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
2026-05-12 22:37:55 +07:00
Denozordec f69e65b014 fix(docker): добавить переменную окружения для внутреннего URL бэкенда
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m34s
Docker images / frontend-image (push) Successful in 1m38s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
2026-05-12 22:21:57 +07:00
Denozordec 49d14a00af fix(data-collection): улучшить обработку ошибок и загрузку данных с API
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 1m41s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
2026-05-12 22:11:49 +07:00
136 changed files with 22092 additions and 5157 deletions
+144
View File
@@ -0,0 +1,144 @@
import re
from pathlib import Path
path = Path(__file__).resolve().parents[2] / "app" / "(main)" / "servers" / "page.tsx"
text = path.read_text(encoding="utf-8")
text = text.replace(
'import { Fragment, useEffect, useMemo, useState } from "react"\n'
'import { PageHeader } from "@/components/page-header"\n'
'import { StatusBadge } from "@/components/status-badge"',
'import { useEffect, useMemo, useState } from "react"\n'
'import { PageHeader } from "@/components/page-header"\n'
'import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"\n'
'import { DataPageToolbar } from "@/components/data-page-toolbar"\n'
'import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"',
1,
)
text = text.replace(
"""import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
} from "@/components/ui/dropdown-menu"
""",
"""import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
""",
1,
)
text = text.replace(
"""import {
SearchIcon, RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
MoreHorizontalIcon, EyeIcon, EyeOffIcon,
ChevronRightIcon, ChevronDownIcon,
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
ShieldIcon, WifiIcon, PencilIcon, PowerIcon, Trash2Icon, ExternalLinkIcon,
HomeIcon, ServerIcon, NetworkIcon,
} from "lucide-react\"""",
"""import {
RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
EyeIcon, EyeOffIcon,
ChevronRightIcon, ChevronDownIcon,
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
ShieldIcon, WifiIcon,
HomeIcon, ServerIcon, NetworkIcon,
} from "lucide-react\"""",
1,
)
text = re.sub(
r"// ─── RouterOS version utilities.*?// ─── Countries ─",
"// ─── Countries ─",
text,
count=1,
flags=re.S,
)
text = re.sub(
r"// ─── Type config ─.*?// ─── Shared small components ─",
"// ─── Shared small components ─",
text,
count=1,
flags=re.S,
)
text = re.sub(
r"function Field\(\{ label, hint, required, children \}:.*?^}\n\n// ─── Country field",
"// ─── Country field",
text,
count=1,
flags=re.S | re.M,
)
text = text.replace(
"const [expandedId, setExpandedId] = useState<string | null>(null)",
"const [sheetStep, setSheetStep] = useState(1)",
1,
)
text = text.replace(
'setForm(defaultForm); setTestState("idle"); setTestMsg("")\n setOpen(true)',
'setForm(defaultForm); setTestState("idle"); setTestMsg("")\n setSheetStep(1)\n setOpen(true)',
1,
)
text = text.replace(
'setTestState("idle"); setTestMsg(""); setOpen(true)',
'setTestState("idle"); setTestMsg(""); setSheetStep(1); setOpen(true)',
1,
)
text = re.sub(r"<Field\b", "<FormField", text)
text = re.sub(r"</Field>", "</FormField>", text)
text = re.sub(r"<Toggle\b", "<FormToggle", text)
new_table = """ {/* Table */}
<Card>
<DataPageToolbar
segmented={{
value: typeFilter,
onChange: setTypeFilter,
options: tabs.map((tab) => ({
value: tab.value,
label: tab.label,
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
})),
}}
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, хосту…"
countLabel={`${filtered.length} серверов`}
/>
<ServersDataGrid
servers={filtered}
isLive={isLive}
pollingIds={pollingIds}
onPoll={handlePoll}
onEdit={openEdit}
onDelete={handleDelete}
onToggleStatus={handleToggleStatus}
/>
</Card>
"""
text = re.sub(
r" \{/\* Table \*/\}\n <Card>.*?</Card>\n",
new_table,
text,
count=1,
flags=re.S,
)
path.write_text(text, encoding="utf-8")
print("patched", path)
+144
View File
@@ -0,0 +1,144 @@
import re
from pathlib import Path
path = Path(__file__).resolve().parents[2] / "app" / "(main)" / "servers" / "page.tsx"
text = path.read_text(encoding="utf-8")
# Wrap sheet form in stepper
text = text.replace(
""" <div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
{/* 1. Основные */}
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>""",
""" <Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
<StepperItem step={1}>
<StepperTrigger>
<StepperIndicator>1</StepperIndicator>
<StepperTitle className="sr-only">Основные</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger>
<StepperIndicator>2</StepperIndicator>
<StepperTitle className="sr-only">WAN</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={3}>
<StepperTrigger>
<StepperIndicator>3</StepperIndicator>
<StepperTitle className="sr-only">API</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={4}>
<StepperTrigger>
<StepperIndicator>4</StepperIndicator>
<StepperTitle className="sr-only">Дополнительно</StepperTitle>
</StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
<StepperContent value={1} className="flex flex-col gap-4">""",
1,
)
text = text.replace(
""" </div>
{/* 2. WAN-аплинки (только для home-router) */}
{isHomeRouter && (
<div className="flex flex-col gap-4">
<SectionTitle>WAN-аплинки</SectionTitle>""",
""" </StepperContent>
<StepperContent value={2} className="flex flex-col gap-4">
<SectionTitle>WAN-аплинки</SectionTitle>""",
1,
)
text = text.replace(
""" </div>
)}
{/* 3. Подключение (API) */}
<div className="flex flex-col gap-4">
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>""",
""" </StepperContent>
<StepperContent value={3} className="flex flex-col gap-4">
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>""",
1,
)
text = text.replace(
""" </div>
{/* 4. Дополнительно */}
<div className="flex flex-col gap-4">
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}""",
""" </StepperContent>
<StepperContent value={4} className="flex flex-col gap-4">
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}""",
1,
)
text = text.replace(
""" </div>
</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" onClick={handleSave}>
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
</Button>
</SheetFooter>""",
""" </StepperContent>
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
{sheetStep > 1 && (
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
)}
{sheetStep < 4 ? (
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
) : (
<Button className="ml-auto" onClick={handleSave}>
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
</Button>
)}
</SheetFooter>""",
1,
)
# WAN step 2: show message when not home router
text = text.replace(
""" <StepperContent value={2} className="flex flex-col gap-4">
<SectionTitle>WAN-аплинки</SectionTitle>
<WanUplinkEditor""",
""" <StepperContent value={2} className="flex flex-col gap-4">
<SectionTitle>WAN-аплинки</SectionTitle>
{!isHomeRouter ? (
<p className="text-sm text-muted-foreground">WAN-аплинки доступны только для типа Home Router.</p>
) : (
<WanUplinkEditor""",
1,
)
text = text.replace(
""" />
</StepperContent>
<StepperContent value={3}""",
""" />
)}
</StepperContent>
<StepperContent value={3}""",
1,
)
path.write_text(text, encoding="utf-8")
print("stepper patched")
+16
View File
@@ -0,0 +1,16 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
+30 -5
View File
@@ -5,15 +5,40 @@ alwaysApply: true
# Локальный запуск проекта
Для запуска всего проекта в dev-режиме поднимать два процесса:
## Требования
- **Node.js 22**, npm с workspaces.
- Первый раз (или после смены зависимостей): `npm install` из корня репозитория.
- Backend: скопировать `backend/.env.example` → `backend/.env` (по умолчанию `PORT=8000`, `CORS_ORIGIN=http://localhost:3000`).
## Запуск (два процесса)
Из корня репозитория поднять **два** long-running процесса в **отдельных** терминалах:
```powershell
npm run dev
npm --prefix backend run dev
```
- Frontend: `http://localhost:3000`
- Backend: `http://localhost:8000`
- Health check backend: `http://localhost:8000/health`
| Сервис | URL | Проверка |
|--------|-----|----------|
| Frontend (Next.js 16, Turbopack) | http://localhost:3000 | открыть в браузере |
| Backend (Fastify) | http://localhost:8000 | `GET /health` |
Если пользователь просит “запусти проект”, “запусти фронт и бэк” или похожую команду, сначала проверь уже запущенные терминалы, затем запускай эти две команды отдельными long-running процессами.
Проверка backend в PowerShell:
```powershell
Invoke-WebRequest -Uri http://localhost:8000/health -UseBasicParsing | Select-Object -ExpandProperty Content
```
Ожидаемый ответ: `{"status":"ok",...}`.
## Поведение агента
Если пользователь просит «запусти проект», «запусти фронт и бэк» или похожее:
1. Сначала проверить уже запущенные терминалы — не дублировать процессы.
2. Запустить обе команды выше как фоновые long-running процессы.
3. Дождаться готовности: frontend — `Ready`, backend — `Server listening` / успешный `/health`.
Подробности архитектуры и env — `README.md`, раздел «Запуск».
+4
View File
@@ -10,3 +10,7 @@
4. Локальный hook `.githooks/commit-msg` отклоняет subject без кириллицы; подключение — `npm install` / `npm run prepare`.
Полные правила: `.cursor/rules/commit-messages-ru.mdc`, semver — `.cursor/rules/release-versioning.mdc`.
## Локальный запуск
Два процесса из корня: `npm run dev` (frontend :3000) и `npm --prefix backend run dev` (backend :8000). Перед первым запуском — `npm install`, для backend — `backend/.env` из `backend/.env.example`. Подробности — `.cursor/rules/dev-run-command.mdc` и `README.md`.
+1
View File
@@ -40,6 +40,7 @@ ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
ENV BACKEND_INTERNAL_URL=http://backend:8000
COPY --from=build /app/public ./public
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
+2 -18
View File
@@ -3,6 +3,7 @@
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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Separator } from "@/components/ui/separator"
@@ -685,23 +686,6 @@ const INIT_TG: TelegramConfig = {
// ─── small helpers ────────────────────────────────────────────────────────────
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
return (
<button role="switch" aria-checked={checked} aria-disabled={disabled} disabled={disabled}
onClick={() => { if (!disabled) onChange(!checked) }}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
disabled && "opacity-50 pointer-events-none",
checked ? "bg-primary" : "bg-muted-foreground/30",
)}>
<span className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform",
checked ? "translate-x-4" : "translate-x-0.5",
)} />
</button>
)
}
function FieldLabel({ children, className }: { children: React.ReactNode; className?: string }) {
return <p className={cn("text-sm font-medium mb-1.5 leading-none", className)}>{children}</p>
}
@@ -739,7 +723,7 @@ function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled }
!rule.enabled && "opacity-55",
)}>
{/* toggle */}
<Toggle checked={rule.enabled} onChange={v => onToggle(rule.id, v)} disabled={interactionsDisabled} />
<FormToggle checked={rule.enabled} onChange={v => onToggle(rule.id, v)} disabled={interactionsDisabled} />
{/* severity dot */}
<SeverityDot severity={rule.severity} />
+46 -53
View File
@@ -1,18 +1,24 @@
"use client"
import { useMemo } from "react"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { DataTable } from "@/components/data-table"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { AsnsDataGrid } from "@/components/data-grids/asns-data-grid"
import { FileImportDialog } from "@/components/file-import-dialog"
import { asns as mockAsns } from "@/lib/data"
import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
export default function AsnsPage() {
const { mode } = useDataSource()
const { enabled, snapshot, loading, error } = useEvoBGP()
const [importOpen, setImportOpen] = useState(false)
const [search, setSearch] = useState("")
const useEvoCatalog = mode === "live" && enabled
@@ -22,13 +28,26 @@ export default function AsnsPage() {
return snapshot?.asns ?? []
}, [useEvoCatalog, loading, snapshot])
const filtered = useMemo(() => {
if (!search) return rows
const q = search.toLowerCase()
return rows.filter(
(r) =>
r.asn.toLowerCase().includes(q) ||
r.org.toLowerCase().includes(q) ||
String(r.prefixes).includes(q),
)
}, [rows, search])
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
actions={
<>
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />Импорт
</Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
<Button size="sm"><PlusIcon className="size-4" />Добавить ASN</Button>
</>
@@ -55,57 +74,31 @@ export default function AsnsPage() {
</p>
)}
</div>
<DataTable
data={rows}
searchPlaceholder="Поиск по ASN, имени, префиксам…"
searchKeys={["asn", "org", "prefixes"]}
columns={[
{
key: "asn",
label: "ASN",
render: (d) => <span className="font-mono font-semibold">{d.asn}</span>,
},
{
key: "org",
label: "Имя / организация",
render: (d) => (
<span className="font-medium max-w-[min(28rem,50vw)] truncate block" title={d.org}>
{d.org}
</span>
),
},
{
key: "prefixes",
label: "Префиксов",
render: (d) => <span className="font-mono tabular-nums">{d.prefixes.toLocaleString("ru")}</span>,
},
{
key: "filter",
label: "Фильтр",
render: (d) => (
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
</span>
),
},
{
key: "updated",
label: "Обновлён",
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
},
{
key: "enabled",
label: "Статус",
render: (d) => (
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
{d.enabled ? "Активен" : "Отключён"}
</span>
),
},
]}
/>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по ASN, имени, префиксам…"
countLabel={`${filtered.length} ASN`}
/>
<AsnsDataGrid
asns={filtered}
isLoading={useEvoCatalog && loading && !snapshot}
pagination={useEvoCatalog}
/>
</DataPageCard>
</div>
</div>
<FileImportDialog
open={importOpen}
onOpenChange={setImportOpen}
title="Импорт ASN"
description="Загрузите CSV или JSON со списком автономных систем"
accept=".csv,.json,text/csv,application/json"
onImport={async (files) => {
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
}}
/>
</div>
)
}
+181 -204
View File
@@ -2,9 +2,14 @@
import { useCallback, useEffect, useMemo, useState } from "react"
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 { StatusBadge } from "@/components/status-badge"
import type { Backup, Server } from "@/lib/data"
import { Card, CardContent } from "@/components/ui/card"
import { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@@ -22,52 +27,17 @@ import { listServers } from "@/shared/api/servers"
import { toFrontendServer } from "@/entities/server/model/mappers"
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
import { toast } from "sonner"
// ─── small UI helpers ─────────────────────────────────────────────────────────
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button type="button" role="switch" aria-checked={checked} onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors ${checked ? "bg-primary" : "bg-input"}`}>
<span className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-4" : "translate-x-0"}`} />
</button>
)
}
function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 pb-1">
<span className="text-muted-foreground">{icon}</span>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{label}</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function SegmentedControl<T extends string>({ value, onChange, options }: {
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
}) {
return (
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
{options.map((o) => (
<button key={o.value} type="button" onClick={() => onChange(o.value)}
className={`px-3 py-1 text-sm rounded transition-colors ${value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
{o.label}
</button>
))}
</div>
)
}
import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
// ─── types ────────────────────────────────────────────────────────────────────
@@ -168,8 +138,11 @@ export default function BackupsPage() {
// Manual backup sheet
const [manualOpen, setManualOpen] = useState(false)
const [manualStep, setManualStep] = useState(1)
const [manualServers, setManualServers] = useState<Set<string>>(new Set())
const [manualNotes, setManualNotes] = useState("")
const [restoreOpen, setRestoreOpen] = useState(false)
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null)
function toggleManualServer(id: string) {
setManualServers((prev) => {
const next = new Set(prev)
@@ -356,7 +329,7 @@ export default function BackupsPage() {
</Button>
<Button
size="sm"
onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualOpen(true) }}
onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualStep(1); setManualOpen(true) }}
disabled={loading}
>
<PlusIcon className="size-4" />Новый бэкап
@@ -424,83 +397,29 @@ export default function BackupsPage() {
{/* ── История ──────────────────────────────────────────────────── */}
{tab === "history" && (
<Card>
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{([
{ value: "all", label: "Все", count: backupList.length },
{ value: "auto", label: "Авто", count: autoCount },
{ value: "manual", label: "Вручную", count: manualCount },
] as { value: KindFilter; label: string; count: number }[]).map((t) => (
<button key={t.value} onClick={() => setKindFilter(t.value)}
className={`flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors ${kindFilter === t.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
{t.label}
<span className="text-xs tabular-nums opacity-60">{t.count}</span>
</button>
))}
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} бэкапов</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3">Файл</th>
<th className="text-left font-medium px-4 py-3">Сервер</th>
<th className="text-left font-medium px-4 py-3">Размер</th>
<th className="text-left font-medium px-4 py-3">Тип</th>
<th className="text-left font-medium px-4 py-3">Заметки</th>
<th className="text-left font-medium px-4 py-3">Создан</th>
<th className="w-28 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.length === 0 && (
<tr><td colSpan={7} className="px-5 py-10 text-center text-sm text-muted-foreground">Нет бэкапов</td></tr>
)}
{filtered.map((b) => (
<tr key={b.id} className="hover:bg-muted/40 transition-colors group">
<td className="px-5 py-3 font-mono text-xs font-medium">{b.filename}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{b.server}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{b.size}</td>
<td className="px-4 py-3">
<span className={cn("text-xs px-2 py-0.5 rounded border font-medium",
b.kind === "manual"
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
: "bg-muted text-muted-foreground border-border"
)}>
{b.kind === "auto" ? "авто" : "вручную"}
</span>
</td>
<td className="px-4 py-3 text-xs text-muted-foreground max-w-[200px] truncate">{b.notes || "—"}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{b.created}</td>
<td className="px-3 py-3">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="size-7"
title="Скачать"
onClick={() => void handleDownload(b.id, b.filename)}
>
<DownloadIcon className="size-3.5" />
</Button>
<Button variant="ghost" size="icon" className="size-7" title="Восстановить">
<RefreshCwIcon className="size-3.5" />
</Button>
<Button variant="ghost" size="icon" className="size-7 text-destructive hover:text-destructive"
title="Удалить" onClick={() => void handleDelete(b.id)}>
<Trash2Icon className="size-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<DataPageCard>
<DataPageToolbar
segmented={{
value: kindFilter,
onChange: setKindFilter,
options: [
{ value: "all", label: "Все", count: backupList.length },
{ value: "auto", label: "Авто", count: autoCount },
{ value: "manual", label: "Вручную", count: manualCount },
],
}}
countLabel={`${filtered.length} бэкапов`}
/>
<BackupsDataGrid
backups={filtered}
onDownload={handleDownload}
onRestore={(b) => {
setRestoreTarget(b)
setRestoreOpen(true)
}}
onDelete={handleDelete}
/>
</DataPageCard>
)}
{/* ── Настройки ────────────────────────────────────────────────── */}
@@ -517,11 +436,11 @@ export default function BackupsPage() {
<p className="text-sm font-medium">Автоматический бэкап</p>
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
</div>
<Toggle checked={schedule.enabled} onChange={(v) => setSched("enabled", v)} />
<FormToggle checked={schedule.enabled} onChange={(v) => setSched("enabled", v)} />
</div>
<div className={cn("flex flex-col gap-4 transition-opacity", !schedule.enabled && "opacity-40 pointer-events-none")}>
<Field label="Частота">
<FormField label="Частота">
<SegmentedControl
value={schedule.frequency}
onChange={(v) => setSched("frequency", v)}
@@ -531,10 +450,10 @@ export default function BackupsPage() {
{ value: "monthly", label: "Ежемесячно" },
]}
/>
</Field>
</FormField>
{schedule.frequency === "weekly" && (
<Field label="День недели">
<FormField label="День недели">
<div className="flex gap-1">
{WEEK_DAYS.map((d, i) => (
<button key={i} type="button" onClick={() => setSched("weekDay", i)}
@@ -548,18 +467,18 @@ export default function BackupsPage() {
</button>
))}
</div>
</Field>
</FormField>
)}
{schedule.frequency === "monthly" && (
<Field label="День месяца" hint="128">
<FormField label="День месяца" hint="128">
<Input type="number" min={1} max={28} className="font-mono w-24"
value={schedule.monthDay}
onChange={(e) => setSched("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))} />
</Field>
</FormField>
)}
<Field label="Время запуска">
<FormField label="Время запуска">
<div className="flex items-center gap-2">
<div className="relative">
<Input type="number" min={0} max={23} className="font-mono w-20 text-center"
@@ -581,15 +500,15 @@ export default function BackupsPage() {
))}
</div>
</div>
</Field>
</FormField>
<div className="grid grid-cols-2 gap-4">
<Field label="Хранить бэкапов" hint="На каждый сервер">
<FormField label="Хранить бэкапов" hint="На каждый сервер">
<Input type="number" min={1} max={90} className="font-mono"
value={schedule.keepCount}
onChange={(e) => setSched("keepCount", Math.max(1, Number(e.target.value)))} />
</Field>
<Field label="Формат файла">
</FormField>
<FormField label="Формат файла">
<SegmentedControl
value={schedule.format}
onChange={(v) => setSched("format", v)}
@@ -598,7 +517,7 @@ export default function BackupsPage() {
{ value: "backup", label: ".backup" },
]}
/>
</Field>
</FormField>
</div>
</div>
</CardContent>
@@ -609,7 +528,7 @@ export default function BackupsPage() {
<CardContent className="px-5 py-5 flex flex-col gap-5">
<SectionTitle icon={<FolderIcon className="size-3.5" />}>Хранилище</SectionTitle>
<Field label="Тип хранилища">
<FormField label="Тип хранилища">
<SegmentedControl
value={storage.type}
onChange={(v) => setStore("type", v)}
@@ -620,45 +539,45 @@ export default function BackupsPage() {
{ value: "smb", label: "SMB" },
]}
/>
</Field>
</FormField>
{storage.type === "local" && (
<Field label="Путь сохранения" hint="Директория на сервере приложения">
<FormField label="Путь сохранения" hint="Директория на сервере приложения">
<Input className="font-mono" placeholder="/var/backup/mikrotik"
value={storage.localPath}
onChange={(e) => setStore("localPath", e.target.value)} />
</Field>
</FormField>
)}
{storage.type !== "local" && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<Field label="Хост">
<FormField label="Хост">
<Input className="font-mono" placeholder="192.168.1.100"
value={storage.host} onChange={(e) => setStore("host", e.target.value)} />
</Field>
</FormField>
</div>
<Field label="Порт">
<FormField label="Порт">
<Input className="font-mono"
placeholder={storage.type === "ftp" ? "21" : storage.type === "scp" ? "22" : "445"}
value={storage.port} onChange={(e) => setStore("port", e.target.value)} />
</Field>
</FormField>
</div>
{storage.type === "smb" && (
<Field label="Общая папка (Share)">
<FormField label="Общая папка (Share)">
<Input className="font-mono" placeholder="backups"
value={storage.share} onChange={(e) => setStore("share", e.target.value)} />
</Field>
</FormField>
)}
<div className="grid grid-cols-2 gap-3">
<Field label="Пользователь">
<FormField label="Пользователь">
<Input className="font-mono" placeholder="backup-user"
value={storage.username} onChange={(e) => setStore("username", e.target.value)} />
</Field>
<Field label={storage.type === "scp" ? "Пароль / ключ" : "Пароль"}>
</FormField>
<FormField label={storage.type === "scp" ? "Пароль / ключ" : "Пароль"}>
<div className="relative">
<Input
type={storage.showPassword ? "text" : "password"}
@@ -673,13 +592,13 @@ export default function BackupsPage() {
{storage.showPassword ? "скрыть" : "показ"}
</button>
</div>
</Field>
</FormField>
</div>
<Field label="Удалённый путь">
<FormField label="Удалённый путь">
<Input className="font-mono" placeholder="/mikrotik-backups"
value={storage.remotePath} onChange={(e) => setStore("remotePath", e.target.value)} />
</Field>
</FormField>
</>
)}
@@ -759,71 +678,129 @@ export default function BackupsPage() {
</div>
{/* ══ Sheet: Manual backup ══════════════════════════════════════════════ */}
<Sheet open={manualOpen} onOpenChange={setManualOpen}>
<Sheet open={manualOpen} onOpenChange={(v) => { setManualOpen(v); if (!v) setManualStep(1) }}>
<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">
<SheetTitle>Новый бэкап</SheetTitle>
<SheetDescription>Снять конфигурацию вручную с выбранных серверов</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between mb-1">
<p className="text-sm font-medium">Выберите серверы</p>
<div className="flex items-center gap-2">
<button type="button" onClick={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
className="text-xs text-primary hover:underline">Все</button>
<span className="text-border">·</span>
<button type="button" onClick={() => setManualServers(new Set())}
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
<Stepper value={manualStep} onValueChange={setManualStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
<StepperItem step={1}>
<StepperTrigger>
<StepperIndicator>1</StepperIndicator>
<StepperTitle className="sr-only">Серверы</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger>
<StepperIndicator>2</StepperIndicator>
<StepperTitle className="sr-only">Заметка</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={3}>
<StepperTrigger>
<StepperIndicator>3</StepperIndicator>
<StepperTitle className="sr-only">Подтверждение</StepperTitle>
</StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
<StepperContent value={1} className="flex flex-col gap-3">
<div className="flex items-center justify-between mb-1">
<p className="text-sm font-medium">Выберите серверы</p>
<div className="flex items-center gap-2">
<button type="button" onClick={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
className="text-xs text-primary hover:underline">Все</button>
<span className="text-border">·</span>
<button type="button" onClick={() => setManualServers(new Set())}
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
</div>
</div>
</div>
{liveServers.map((s) => {
const checked = manualServers.has(s.id)
return (
<button key={s.id} type="button" onClick={() => toggleManualServer(s.id)}
className={cn(
"flex items-center gap-3 rounded-lg border p-3 text-left transition-colors",
checked ? "border-primary/40 bg-primary/5" : "border-border hover:bg-muted/40"
)}>
<div className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
checked ? "bg-primary border-primary" : "border-border"
)}>
{checked && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l2.5 2.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{s.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs font-mono text-muted-foreground">{s.host}</span>
<StatusBadge status={s.status} />
{liveServers.map((s) => {
const checked = manualServers.has(s.id)
return (
<button key={s.id} type="button" onClick={() => toggleManualServer(s.id)}
className={cn(
"flex items-center gap-3 rounded-lg border p-3 text-left transition-colors",
checked ? "border-primary/40 bg-primary/5" : "border-border hover:bg-muted/40"
)}>
<div className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
checked ? "bg-primary border-primary" : "border-border"
)}>
{checked && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l2.5 2.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</div>
</div>
{s.status === "offline" && (
<span className="text-xs text-muted-foreground">недоступен</span>
)}
</button>
)
})}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Заметка</label>
<Input placeholder="Например: перед обновлением BGP"
value={manualNotes} onChange={(e) => setManualNotes(e.target.value)} />
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{s.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs font-mono text-muted-foreground">{s.host}</span>
<StatusBadge status={s.status} />
</div>
</div>
{s.status === "offline" && (
<span className="text-xs text-muted-foreground">недоступен</span>
)}
</button>
)
})}
</StepperContent>
<StepperContent value={2} className="flex flex-col gap-4">
<FormField label="Заметка">
<Input placeholder="Например: перед обновлением BGP"
value={manualNotes} onChange={(e) => setManualNotes(e.target.value)} />
</FormField>
</StepperContent>
<StepperContent value={3} className="flex flex-col gap-3 text-sm">
<p className="text-muted-foreground">
Будет создан бэкап для <strong className="text-foreground">{manualServers.size}</strong> серверов.
</p>
{manualNotes && (
<p className="text-muted-foreground">Заметка: {manualNotes}</p>
)}
</StepperContent>
</StepperPanel>
</Stepper>
<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"
disabled={manualServers.size === 0 || backupJobId !== null}
onClick={handleManualBackup}>
Снять бэкап ({manualServers.size})
</Button>
{manualStep > 1 && (
<Button variant="outline" className="flex-1" onClick={() => setManualStep((s) => s - 1)}>
Назад
</Button>
)}
{manualStep < 3 ? (
<Button
className="flex-1"
disabled={manualStep === 1 && manualServers.size === 0}
onClick={() => setManualStep((s) => s + 1)}
>
Далее
</Button>
) : (
<Button className="flex-1"
disabled={manualServers.size === 0 || backupJobId !== null}
onClick={handleManualBackup}>
Снять бэкап ({manualServers.size})
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
<FileImportDialog
open={restoreOpen}
onOpenChange={setRestoreOpen}
title={restoreTarget ? `Восстановление: ${restoreTarget.filename}` : "Восстановление бэкапа"}
description="Выберите файл конфигурации для загрузки на роутер"
accept=".backup,.rsc,.zip"
onImport={async (files) => {
toast.success(`Файл ${files[0]?.name} подготовлен к восстановлению на ${restoreTarget?.server ?? "сервер"}`)
}}
/>
</div>
)
}
+78 -293
View File
@@ -2,7 +2,15 @@
import { Fragment, useState, useMemo, useEffect } from "react"
import { PageHeader } from "@/components/page-header"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
import type { Filter } from "@/components/reui/filters"
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 { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
@@ -17,49 +25,12 @@ import { useDataSource } from "@/lib/data-source"
// ─── types ────────────────────────────────────────────────────────────────────
type BgpState = "Established" | "Active" | "Idle" | "Connect" | "OpenSent" | "OpenConfirm"
type BgpType = "eBGP" | "iBGP"
type BgpAfi = "IPv4 Unicast" | "IPv6 Unicast" | "VPNv4 Unicast"
type BgpSession = BgpSessionRow
type BgpTab = "sessions" | "routers" | "analytics"
type StateFilter = "all" | BgpState
type TypeFilter = "all" | BgpType
interface BgpSession {
id: string
serverId: string
serverLabel: string
serverSite: string
peerIp: string
remoteAs: number
localAs: number
routerId: string
description: string
state: BgpState
type: BgpType
afi: BgpAfi
uptime: string | null
holdTime: number
keepalive: number
prefixesRx: number
prefixesTx: number
prefixesActive: number
inputMessages: number
outputMessages: number
capabilities: string[]
lastError: string | null
}
// ─── AS name lookup ───────────────────────────────────────────────────────────
const AS_NAMES: Record<number, string> = {
8359: "МТС / Tele2",
13238: "Яндекс",
12389: "Ростелеком",
24940: "Hetzner",
6777: "AMS-IX",
1299: "Telia",
65001: "iBGP internal",
}
const AS_NAMES = BGP_AS_NAMES
// ─── mock data ────────────────────────────────────────────────────────────────
const SESSIONS: BgpSession[] = [
@@ -240,13 +211,6 @@ function TypeBadge({ type }: { type: BgpType }) {
)
}
function CapChip({ cap }: { cap: string }) {
return (
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border bg-muted/60 text-muted-foreground border-border/60">
{cap}
</span>
)
}
function fmtNum(n: number) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
@@ -254,108 +218,6 @@ function fmtNum(n: number) {
return String(n)
}
function PrefixBar({ rx, tx, active }: { rx: number; tx: number; active: number }) {
const max = Math.max(rx, 1)
return (
<div className="flex flex-col gap-1.5 text-[10px] font-mono">
{[
{ label: "Получено", val: rx, color: "bg-[var(--chart-rx)]", w: rx / max },
{ label: "Активных", val: active, color: "bg-[var(--chart-1)]", w: active / max },
{ label: "Отправлено", val: tx, color: "bg-[var(--chart-tx)]", w: Math.min(tx / max, 1) },
].map(r => (
<div key={r.label} className="flex items-center gap-2">
<span className="w-20 text-muted-foreground shrink-0">{r.label}</span>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full", r.color)}
style={{ width: `${Math.max(r.w * 100, r.val > 0 ? 2 : 0)}%` }} />
</div>
<span className="w-14 text-right tabular-nums">{fmtNum(r.val)}</span>
</div>
))}
</div>
)
}
// ─── RSC snippet ──────────────────────────────────────────────────────────────
function rscSnippet(s: BgpSession) {
return `/routing bgp connection\nadd name=peer-as${s.remoteAs} remote.address=${s.peerIp}/32 \\\n remote.as=${s.remoteAs} local.role=${s.type === "eBGP" ? "ebgp" : "ibgp"} \\\n output.filter-chain=export-filter input.filter=import-filter \\\n routing-table=main`
}
// ─── session expanded row ─────────────────────────────────────────────────────
function SessionDetail({ s }: { s: BgpSession }) {
const [copied, setCopied] = useState(false)
function copy() {
navigator.clipboard.writeText(rscSnippet(s)).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 1800)
})
}
return (
<div className="px-4 pb-4 pt-2 bg-muted/20 border-t border-border/60">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
{[
{ label: "Router ID", value: s.routerId },
{ label: "Hold / KA", value: `${s.holdTime}s / ${s.keepalive}s` },
{ label: "AFI/SAFI", value: s.afi },
{ label: "Сообщения ↓/↑", value: `${fmtNum(s.inputMessages)} / ${fmtNum(s.outputMessages)}` },
].map(({ label, value }) => (
<div key={label}>
<p className="text-[10px] text-muted-foreground mb-0.5">{label}</p>
<p className="text-xs font-mono font-medium">{value}</p>
</div>
))}
</div>
{/* prefix bars */}
{s.state === "Established" && (
<div className="mb-4">
<p className="text-[10px] text-muted-foreground mb-2 uppercase tracking-wider font-semibold">Префиксы</p>
<PrefixBar rx={s.prefixesRx} tx={s.prefixesTx} active={s.prefixesActive} />
</div>
)}
{/* capabilities */}
{s.capabilities.length > 0 && (
<div className="mb-4">
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">Capabilities</p>
<div className="flex flex-wrap gap-1.5">
{s.capabilities.map(c => <CapChip key={c} cap={c} />)}
</div>
</div>
)}
{/* last error */}
{s.lastError && (
<div className="mb-4 flex items-center gap-2 rounded-md border border-red-500/20 bg-red-500/5 px-3 py-2">
<span className="size-1.5 rounded-full bg-red-500 shrink-0" />
<p className="text-xs font-mono text-red-500">{s.lastError}</p>
</div>
)}
{/* rsc export */}
<div className="mt-2">
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">RouterOS Export</p>
<div className="rounded-md bg-[#0a0f1a] border border-white/8 px-3 py-2.5 flex items-start justify-between gap-3">
<pre className="text-[10px] font-mono text-[#94a3b8] leading-relaxed whitespace-pre-wrap flex-1 min-w-0">
{rscSnippet(s)}
</pre>
<button onClick={copy}
className={cn(
"shrink-0 flex items-center gap-1 text-[10px] px-2 py-1 rounded border transition-colors",
copied
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-400"
: "border-white/10 text-white/40 hover:text-white/70 hover:border-white/20",
)}>
<ClipboardCopyIcon className="size-3" />
{copied ? "Скопировано" : "Копировать"}
</button>
</div>
</div>
</div>
)
}
// ─── backend mapping ──────────────────────────────────────────────────────────
interface BackendBgpSession {
@@ -397,159 +259,82 @@ function backendToFrontend(b: BackendBgpSession): BgpSession {
// ─── sessions tab ─────────────────────────────────────────────────────────────
const STATE_FILTERS: Array<{ value: StateFilter; label: string }> = [
{ value: "all", label: "Все" },
{ value: "Established", label: "Established" },
{ value: "Active", label: "Active" },
{ value: "Idle", label: "Idle" },
{ value: "OpenSent", label: "OpenSent" },
]
const BGP_FILTER_ACCESSORS = {
state: (s: BgpSession) => s.state,
type: (s: BgpSession) => s.type,
afi: (s: BgpSession) => s.afi,
}
function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
const [search, setSearch] = useState("")
const [search, setSearch] = useState("")
const [stateFilter, setStateFilter] = useState<StateFilter>("all")
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [expandedId, setExpandedId] = useState<string | null>(null)
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
const q = search.toLowerCase()
const filtered = useMemo(() => sessions.filter(s => {
if (stateFilter !== "all" && s.state !== stateFilter) return false
if (typeFilter !== "all" && s.type !== typeFilter) return false
if (q && !s.peerIp.includes(q) && !s.description.toLowerCase().includes(q)
&& !s.serverLabel.includes(q) && !String(s.remoteAs).includes(q)
&& !(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)) return false
return true
}), [sessions, q, stateFilter, typeFilter])
const filtered = useMemo(() => {
const base = sessions.filter((s) => {
if (stateFilter !== "all" && s.state !== stateFilter) return false
if (typeFilter !== "all" && s.type !== typeFilter) return false
if (
q &&
!s.peerIp.includes(q) &&
!s.description.toLowerCase().includes(q) &&
!s.serverLabel.includes(q) &&
!String(s.remoteAs).includes(q) &&
!(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)
) {
return false
}
return true
})
return applyReuiFilters(base, advancedFilters, BGP_FILTER_ACCESSORS)
}, [sessions, q, stateFilter, typeFilter, advancedFilters])
return (
<div className="flex flex-col gap-4">
{/* filter bar */}
<div className="flex items-center gap-2 flex-wrap">
{/* search */}
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none z-10" />
<Input
value={search} onChange={e => setSearch(e.target.value)}
placeholder="IP, AS, описание…"
className="h-8 pl-8 pr-8 w-52 text-xs"
/>
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground z-10">
<XIcon className="size-3" />
</button>
)}
</div>
{/* state filter */}
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
{STATE_FILTERS.map(f => (
<button key={f.value} onClick={() => setStateFilter(f.value)}
className={cn(
"px-2.5 py-1 text-[11px] rounded transition-colors whitespace-nowrap",
stateFilter === f.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}>
{f.label}
</button>
))}
</div>
{/* type filter */}
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
{(["all", "eBGP", "iBGP"] as const).map(t => (
<button key={t} onClick={() => setTypeFilter(t)}
className={cn(
"px-2.5 py-1 text-[11px] rounded transition-colors",
typeFilter === t ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}>
{t === "all" ? "Все типы" : t}
</button>
))}
</div>
<span className="text-xs text-muted-foreground ml-auto">
{filtered.length} из {sessions.length}
</span>
</div>
{/* table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/40">
<th className="w-8" />
{["Роутер", "Peer IP", "Remote AS", "Описание", "Тип", "Состояние", "Uptime", "Prefixes ↓", "Prefixes ↑"].map(h => (
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{filtered.map(s => {
const isOpen = expandedId === s.id
return (
<Fragment key={s.id}>
<tr
onClick={() => setExpandedId(isOpen ? null : s.id)}
className={cn(
"cursor-pointer transition-colors",
isOpen ? "bg-muted/30" : "hover:bg-muted/20",
)}>
<td className="pl-3 py-2.5">
{isOpen
? <ChevronDownIcon className="size-3.5 text-muted-foreground" />
: <ChevronRightIcon className="size-3.5 text-muted-foreground" />}
</td>
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{s.serverLabel}</td>
<td className="px-3 py-2.5 font-mono">{s.peerIp}</td>
<td className="px-3 py-2.5 font-mono">
<div className="flex items-center gap-1.5">
<span>AS{s.remoteAs}</span>
{AS_NAMES[s.remoteAs] && (
<span className="text-muted-foreground text-[10px]">{AS_NAMES[s.remoteAs]}</span>
)}
</div>
</td>
<td className="px-3 py-2.5 text-muted-foreground max-w-[180px] truncate">{s.description}</td>
<td className="px-3 py-2.5"><TypeBadge type={s.type} /></td>
<td className="px-3 py-2.5"><StateBadge state={s.state} /></td>
<td className="px-3 py-2.5 font-mono tabular-nums text-muted-foreground">
{s.uptime ?? "—"}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-right">
{s.prefixesRx > 0
? <span className="text-emerald-600 dark:text-emerald-400">{fmtNum(s.prefixesRx)}</span>
: <span className="text-muted-foreground"></span>}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-right">
{s.prefixesTx > 0
? <span className="text-[var(--chart-tx)]">{fmtNum(s.prefixesTx)}</span>
: <span className="text-muted-foreground"></span>}
</td>
</tr>
{isOpen && (
<tr>
<td colSpan={10} className="p-0">
<SessionDetail s={s} />
</td>
</tr>
)}
</Fragment>
)
})}
{filtered.length === 0 && (
<tr>
<td colSpan={10} className="px-4 py-8 text-center text-sm text-muted-foreground">
Нет сессий по заданным фильтрам
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
<DataPageCard>
<DataPageToolbar
segmented={{
value: stateFilter,
onChange: setStateFilter,
options: [
{ value: "all", label: "Все", count: sessions.length },
{ value: "Established", label: "Established", count: sessions.filter((s) => s.state === "Established").length },
{ value: "Active", label: "Active", count: sessions.filter((s) => s.state === "Active").length },
{ value: "Idle", label: "Idle", count: sessions.filter((s) => s.state === "Idle").length },
{ value: "OpenSent", label: "OpenSent", count: sessions.filter((s) => s.state === "OpenSent").length },
],
}}
filters={advancedFilters}
onFiltersChange={setAdvancedFilters}
filterFields={BGP_FILTER_FIELDS}
search={search}
onSearchChange={setSearch}
searchPlaceholder="IP, AS, описание…"
countLabel={`${filtered.length} из ${sessions.length}`}
actions={
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
{(["all", "eBGP", "iBGP"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setTypeFilter(t)}
className={cn(
"px-2.5 py-1 text-[11px] rounded transition-colors",
typeFilter === t
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t === "all" ? "Все типы" : t}
</button>
))}
</div>
}
/>
<BgpSessionsDataGrid sessions={filtered} />
</DataPageCard>
</div>
)
}
+136 -414
View File
@@ -1,12 +1,15 @@
"use client"
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { FileImportDialog } from "@/components/file-import-dialog"
import { routerCertificates, servers as mockServers } from "@/lib/data"
import type { CertStatus, Server } from "@/lib/data"
import type { CertificateDto } from "@mmapp/contracts/certificates"
import { Flag } from "@/components/flag"
import { Card, CardContent } from "@/components/ui/card"
import { DataPageCard } from "@/components/data-page-card"
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@@ -35,117 +38,24 @@ import { toast } from "sonner"
import {
SearchIcon,
ShieldCheckIcon,
ShieldAlertIcon,
ShieldOffIcon,
BadgeCheckIcon,
AlertTriangleIcon,
AlertCircleIcon,
CalendarIcon,
KeyRoundIcon,
ServerIcon,
PlusIcon,
ChevronDownIcon,
ChevronRightIcon,
RefreshCwIcon,
UploadIcon,
} from "lucide-react"
const STATUS_CONFIG: Record<
CertStatus,
{
label: string
icon: ReactNode
badge: string
row: string
}
> = {
valid: {
label: "Действителен",
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />,
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
row: "",
},
expired: {
label: "Истёк",
icon: <ShieldOffIcon className="size-4 text-red-500" />,
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
row: "bg-red-500/5",
},
revoked: {
label: "Отозван",
icon: <ShieldAlertIcon className="size-4 text-amber-500" />,
badge: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
row: "bg-amber-500/5",
},
}
const CERT_TABLE_GRID_CLASS =
"grid grid-cols-[1.25rem_minmax(0,1.35fr)_minmax(0,0.85fr)_minmax(0,1fr)_9.5rem_minmax(0,8.5rem)_6rem] gap-3"
function daysLeftColor(days: number): string {
if (days < 0) return "text-red-500"
if (days <= 7) return "text-red-500"
if (days <= 30) return "text-amber-500"
return "text-emerald-600 dark:text-emerald-400"
}
function daysLeftBar(days: number, total = 365): number {
if (days <= 0) return 0
return Math.min(100, Math.round((days / total) * 100))
}
function Field({
label,
hint,
required,
children,
}: {
label: string
hint?: string
required?: boolean
children: ReactNode
}) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{label}
{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
checked ? "bg-primary" : "bg-input",
)}
>
<span
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0",
)}
/>
</button>
)
}
function SectionTitle({ children }: { children: ReactNode }) {
return (
<div className="flex items-center gap-2 py-0.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
return {
@@ -165,203 +75,6 @@ function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
}
}
function CertPartDaysBar({ cert, pct }: { cert: CertificateDto; pct: number }) {
return (
<div
className={cn(
"h-full rounded-full transition-all",
cert.daysLeft < 0
? "bg-red-500"
: cert.daysLeft <= 7
? "bg-red-500"
: cert.daysLeft <= 30
? "bg-amber-500"
: "bg-emerald-500",
)}
style={{ width: `${pct}%` }}
/>
)
}
function CertPartDays({ cert, pct }: { cert: CertificateDto; pct: number }) {
return (
<>
<div className="flex items-center justify-between text-xs mb-1">
<span className={cn("font-mono font-medium", daysLeftColor(cert.daysLeft))}>
{cert.daysLeft < 0 ? `Истёк ${-cert.daysLeft}д назад` : `${cert.daysLeft}д осталось`}
</span>
<span className="text-muted-foreground text-[10px]">{cert.validUntil}</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<CertPartDaysBar cert={cert} pct={pct} />
</div>
</>
)
}
function CertPartDetailDates({ cert }: { cert: CertificateDto }) {
return (
<div>
<p className="text-muted-foreground mb-1">Действителен с</p>
<p className="font-mono">{cert.validFrom}</p>
</div>
)
}
function CertPartDetailSans({ cert }: { cert: CertificateDto }) {
return (
<div>
<p className="text-muted-foreground mb-1">SAN / Alt Names</p>
<div className="flex flex-wrap gap-1">
{cert.sans.length > 0
? cert.sans.map((s) => (
<span key={s} className="font-mono bg-muted px-1.5 py-0.5 rounded">
{s}
</span>
))
: <span className="text-muted-foreground"></span>}
</div>
</div>
)
}
function CertPartDetailTrusted({ cert }: { cert: CertificateDto }) {
return (
<div>
<p className="text-muted-foreground mb-1">Trusted</p>
<p className={cert.trusted ? "text-emerald-600 dark:text-emerald-400" : "text-red-500"}>
{cert.trusted ? "Да (доверенный)" : "Нет (не доверенный)"}
</p>
</div>
)
}
function CertListRow({
cert,
cfg,
pct,
server,
expanded,
onToggle,
}: {
cert: CertificateDto
cfg: (typeof STATUS_CONFIG)[CertStatus]
pct: number
server?: Server
expanded: boolean
onToggle: () => void
}) {
return (
<div className={cn("border-b last:border-b-0", cfg.row)}>
<div
className={cn(
CERT_TABLE_GRID_CLASS,
"px-4 py-3 items-center hover:bg-muted/30 transition-colors cursor-pointer",
)}
onClick={onToggle}
>
<button
type="button"
className="text-muted-foreground"
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
>
{expanded ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
</button>
<div className="min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="shrink-0">{cfg.icon}</span>
<span className="font-medium text-sm truncate" title={cert.name}>
{cert.name}
</span>
</div>
<p className="text-xs text-muted-foreground font-mono mt-0.5 truncate" title={cert.commonName}>
{cert.commonName}
</p>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
{server
? (
<>
<Flag code={server.country} size={12} />
<span className="font-mono truncate">{server.name}</span>
</>
)
: (
<>
<ServerIcon className="size-3.5" />
<span className="font-mono truncate">{cert.serverName ?? cert.serverId}</span>
</>
)}
</div>
<p className="text-xs text-muted-foreground truncate min-w-0" title={cert.issuedBy}>
{cert.issuedBy}
</p>
<div className="min-w-0">
<CertPartDays cert={cert} pct={pct} />
</div>
<div className="flex flex-wrap gap-1 min-w-0 overflow-hidden">
{cert.usage.map((u) => (
<span
key={u}
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground border"
>
{u}
</span>
))}
</div>
<span
className={cn(
"text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap shrink-0 justify-self-end",
cfg.badge,
)}
>
{cfg.label}
</span>
</div>
{expanded && (
<div className="px-10 pb-4 grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs border-t border-border/50 pt-3">
<div>
<p className="text-muted-foreground mb-1">Key size</p>
<p className="font-mono font-medium">{cert.keySize} bit</p>
</div>
<CertPartDetailDates cert={cert} />
<CertPartDetailSans cert={cert} />
<CertPartDetailTrusted cert={cert} />
</div>
)}
</div>
)
}
function CertRow({
cert,
server,
expanded,
onToggle,
}: {
cert: CertificateDto
server?: Server
expanded: boolean
onToggle: () => void
}) {
const cfg = STATUS_CONFIG[cert.status]
const pct = daysLeftBar(cert.daysLeft)
return (
<CertListRow
cert={cert}
cfg={cfg}
pct={pct}
server={server}
expanded={expanded}
onToggle={onToggle}
/>
)
}
function CertPartAlertExpired({ expired }: { expired: CertificateDto[] }) {
return (
<div className="flex items-start gap-3 rounded-lg bg-red-500/5 border border-red-500/20 px-4 py-3 text-sm">
@@ -576,43 +289,6 @@ function CertPartTableToolbar({
)
}
function CertPartTableHeaderDates() {
return (
<div className="flex items-center gap-1">
<CalendarIcon className="size-3" />
Срок
</div>
)
}
function CertPartTableHeaderUsage() {
return (
<div className="flex items-center gap-1">
<KeyRoundIcon className="size-3" />
Использование
</div>
)
}
function CertPartTableHeader() {
return (
<div
className={cn(
CERT_TABLE_GRID_CLASS,
"px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20",
)}
>
<span />
<span>Имя / CN</span>
<span>Сервер</span>
<span>Выпущен</span>
<CertPartTableHeaderDates />
<CertPartTableHeaderUsage />
<span>Статус</span>
</div>
)
}
function CertPartReference() {
return (
<Card>
@@ -679,6 +355,7 @@ function CertPartIssueForm({
setIssueTrustWww,
issueTrustApi,
setIssueTrustApi,
step,
}: {
serverList: Server[]
issueServerId: string
@@ -693,12 +370,15 @@ function CertPartIssueForm({
setIssueTrustWww: (v: boolean) => void
issueTrustApi: boolean
setIssueTrustApi: (v: boolean) => void
step?: 1 | 2 | 3 | 4
}) {
const showAll = step == null
return (
<div className="flex flex-col gap-5">
{(showAll || step === 1) && (
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<Field label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
<FormField label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
<select
value={issueServerId}
onChange={(e) => setIssueServerId(e.target.value)}
@@ -713,41 +393,45 @@ function CertPartIssueForm({
</option>
))}
</select>
</Field>
<Field label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
</FormField>
<FormField label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
<Input
className="font-mono"
value={issueCertName}
onChange={(e) => setIssueCertName(e.target.value)}
placeholder="router-le"
/>
</Field>
</FormField>
</div>
)}
{(showAll || step === 2) && (
<div className="flex flex-col gap-4">
<SectionTitle>Домены</SectionTitle>
<Field label="Common Name" required hint="Основное имя в сертификате">
<FormField label="Common Name" required hint="Основное имя в сертификате">
<Input
className="font-mono"
value={issueCommonName}
onChange={(e) => setIssueCommonName(e.target.value)}
placeholder="vpn.example.com"
/>
</Field>
<Field label="SAN" hint="По одному имени в строке">
</FormField>
<FormField label="SAN" hint="По одному имени в строке">
<textarea
className="min-h-24 w-full rounded-lg border border-input bg-background px-2.5 py-2 text-sm font-mono text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
value={issueSans}
onChange={(e) => setIssueSans(e.target.value)}
placeholder="www.example.com"
/>
</Field>
</FormField>
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground mb-1">Let&apos;s Encrypt · DNS-01 (Cloudflare)</p>
<p>TXT-запись создаётся в Cloudflare, сертификат импортируется на выбранный RouterOS.</p>
</div>
</div>
)}
{(showAll || step === 3) && (
<div className="flex flex-col gap-4">
<SectionTitle>Импорт на RouterOS</SectionTitle>
<div className="flex items-center justify-between">
@@ -755,16 +439,29 @@ function CertPartIssueForm({
<p className="text-sm font-medium">Trust store · www</p>
<p className="text-xs text-muted-foreground">Веб-интерфейс и HTTPS-сервисы</p>
</div>
<Toggle checked={issueTrustWww} onChange={setIssueTrustWww} />
<FormToggle checked={issueTrustWww} onChange={setIssueTrustWww} />
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Trust store · api</p>
<p className="text-xs text-muted-foreground">REST API и управление</p>
</div>
<Toggle checked={issueTrustApi} onChange={setIssueTrustApi} />
<FormToggle checked={issueTrustApi} onChange={setIssueTrustApi} />
</div>
</div>
)}
{(showAll || step === 4) && (
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm">
<p className="font-medium mb-2">Проверьте параметры</p>
<ul className="text-xs text-muted-foreground space-y-1">
<li>Сервер: {serverList.find((s) => s.id === issueServerId)?.name ?? "—"}</li>
<li>Имя: {issueCertName || "—"}</li>
<li>CN: {issueCommonName || "—"}</li>
<li>Trust www: {issueTrustWww ? "да" : "нет"} · api: {issueTrustApi ? "да" : "нет"}</li>
</ul>
</div>
)}
</div>
)
}
@@ -776,13 +473,14 @@ export default function CertificatesPage() {
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<CertStatus | "all">("all")
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const [certificates, setCertificates] = useState<CertificateDto[]>([])
const [loadState, setLoadState] = useState<"idle" | "loading" | "error">("idle")
const [loadError, setLoadError] = useState<string | null>(null)
const [serverList, setServerList] = useState<Server[]>([])
const [issueOpen, setIssueOpen] = useState(false)
const [issueStep, setIssueStep] = useState(1)
const [importOpen, setImportOpen] = useState(false)
const [issueBusy, setIssueBusy] = useState(false)
const [issueServerId, setIssueServerId] = useState("")
const [issueCertName, setIssueCertName] = useState("")
@@ -888,15 +586,6 @@ export default function CertificatesPage() {
})
}, [displayCerts, search, statusFilter])
function toggleExpand(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
async function handleRefresh() {
if (!liveReady) return
try {
@@ -1004,7 +693,11 @@ export default function CertificatesPage() {
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
Обновить
</Button>
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => setIssueOpen(true)}>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />
Импорт
</Button>
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => { setIssueStep(1); setIssueOpen(true) }}>
<PlusIcon className="size-4" />
Выпустить сертификат
</Button>
@@ -1053,7 +746,7 @@ export default function CertificatesPage() {
/>
)}
<Card>
<DataPageCard>
<CertPartTableToolbar
search={search}
setSearch={setSearch}
@@ -1061,36 +754,18 @@ export default function CertificatesPage() {
setStatusFilter={setStatusFilter}
filteredCount={filtered.length}
/>
<div className="overflow-x-auto">
<div className="min-w-[48rem]">
<CertPartTableHeader />
{prefsHydrated && isLive && loadState === "loading" && displayCerts.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">Загрузка сертификатов</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<ShieldCheckIcon className="size-10 mb-3 opacity-20" />
<p className="text-sm font-medium">Сертификаты не найдены</p>
</div>
) : (
filtered.map((cert) => (
<CertRow
key={cert.id}
cert={cert}
server={serverById.get(cert.serverId)}
expanded={expandedIds.has(cert.id)}
onToggle={() => toggleExpand(cert.id)}
/>
))
)}
</div>
</div>
</Card>
<CertificatesDataGrid
certificates={filtered}
serverMap={serverById}
isLoading={prefsHydrated && isLive && loadState === "loading" && displayCerts.length === 0}
/>
</DataPageCard>
<CertPartReference />
</div>
</div>
<Sheet open={issueOpen} onOpenChange={setIssueOpen}>
<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">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Выпуск сертификата</SheetTitle>
@@ -1099,40 +774,87 @@ export default function CertificatesPage() {
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5">
<CertPartIssueForm
serverList={serverList}
issueServerId={issueServerId}
setIssueServerId={setIssueServerId}
issueCertName={issueCertName}
setIssueCertName={setIssueCertName}
issueCommonName={issueCommonName}
setIssueCommonName={setIssueCommonName}
issueSans={issueSans}
setIssueSans={setIssueSans}
issueTrustWww={issueTrustWww}
setIssueTrustWww={setIssueTrustWww}
issueTrustApi={issueTrustApi}
setIssueTrustApi={setIssueTrustApi}
/>
</div>
<Stepper value={issueStep} onValueChange={setIssueStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
{[
{ step: 1, title: "Основные" },
{ step: 2, title: "Домены" },
{ step: 3, title: "Импорт" },
{ step: 4, title: "Проверка" },
].map(({ step, title }, i, arr) => (
<StepperItem key={step} step={step}>
<StepperTrigger>
<StepperIndicator>{step}</StepperIndicator>
<StepperTitle className="sr-only">{title}</StepperTitle>
</StepperTrigger>
{i < arr.length - 1 && <StepperSeparator />}
</StepperItem>
))}
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
{[1, 2, 3, 4].map((s) => (
<StepperContent key={s} value={s}>
<CertPartIssueForm
step={s as 1 | 2 | 3 | 4}
serverList={serverList}
issueServerId={issueServerId}
setIssueServerId={setIssueServerId}
issueCertName={issueCertName}
setIssueCertName={setIssueCertName}
issueCommonName={issueCommonName}
setIssueCommonName={setIssueCommonName}
issueSans={issueSans}
setIssueSans={setIssueSans}
issueTrustWww={issueTrustWww}
setIssueTrustWww={setIssueTrustWww}
issueTrustApi={issueTrustApi}
setIssueTrustApi={setIssueTrustApi}
/>
</StepperContent>
))}
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={issueBusy} />}>
Отмена
</SheetClose>
<Button
className="flex-1"
disabled={!liveReady || issueBusy}
onClick={() => {
void handleIssue()
}}
>
{issueBusy ? "Выпуск…" : "Выпустить"}
</Button>
{issueStep > 1 && (
<Button variant="outline" className="flex-1" disabled={issueBusy} onClick={() => setIssueStep((s) => s - 1)}>
Назад
</Button>
)}
{issueStep < 4 ? (
<Button
className="flex-1"
disabled={issueStep === 1 && (!issueServerId || !issueCertName)}
onClick={() => setIssueStep((s) => s + 1)}
>
Далее
</Button>
) : (
<Button
className="flex-1"
disabled={!liveReady || issueBusy}
onClick={() => { void handleIssue() }}
>
{issueBusy ? "Выпуск…" : "Выпустить"}
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
<FileImportDialog
open={importOpen}
onOpenChange={setImportOpen}
title="Импорт сертификата"
description="Загрузите PEM, CRT или PKCS#12 для импорта на RouterOS"
accept=".pem,.crt,.cer,.p12,.pfx"
onImport={async (files) => {
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
}}
/>
</div>
)
}
+21 -117
View File
@@ -2,14 +2,22 @@
import { useState, useMemo, useEffect } from "react"
import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card"
import {
CommunitiesDataGrid,
type CommunityRow,
TYPE_LABELS,
ACTION_LABELS,
ACTION_COLOR,
} from "@/components/data-grids/communities-data-grid"
import {
Card, CardContent, CardHeader, CardTitle, CardDescription,
} from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
PlusIcon, SearchIcon, TagIcon, ServerIcon, FilterIcon,
ChevronRightIcon, CopyIcon, CheckIcon, TrashIcon, PencilIcon,
PlusIcon, SearchIcon, TagIcon, FilterIcon,
CopyIcon, CheckIcon, TrashIcon, PencilIcon,
LoaderCircleIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
@@ -19,21 +27,8 @@ import { useEvoBGP } from "@/lib/evobgp-context"
// ─── types ────────────────────────────────────────────────────────────────────
type CommType = "standard" | "no-export" | "no-advertise" | "local-as" | "custom"
interface Community {
id: string
value: string // e.g. "65001:100"
name: string
description: string
type: CommType
filterIds: string[] // which filters use this community
serverCount: number
prefixCount: number
action: "permit" | "deny" | "local-pref" | "metric"
actionValue?: number // e.g. local-pref value
enabled: boolean
}
type Community = CommunityRow
type CommType = CommunityRow["type"]
// ─── mock data ────────────────────────────────────────────────────────────────
@@ -100,28 +95,6 @@ const COMMUNITIES: Community[] = [
},
]
const TYPE_LABELS: Record<CommType, string> = {
"standard": "Стандартный",
"no-export": "No-export",
"no-advertise":"No-advertise",
"local-as": "Local-AS",
"custom": "Кастомный",
}
const ACTION_LABELS: Record<Community["action"], string> = {
"permit": "Permit",
"deny": "Deny",
"local-pref": "Local-pref",
"metric": "MED/Metric",
}
const ACTION_COLOR: Record<Community["action"], string> = {
"permit": "text-emerald-500",
"deny": "text-red-500",
"local-pref": "text-blue-500",
"metric": "text-amber-500",
}
// ─── page ─────────────────────────────────────────────────────────────────────
export default function CommunitiesPage() {
@@ -225,84 +198,15 @@ export default function CommunitiesPage() {
</div>
{/* list */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-muted-foreground">
<th className="text-left font-medium px-4 py-2.5">Community</th>
<th className="text-left font-medium px-4 py-2.5">Имя / описание</th>
<th className="text-left font-medium px-4 py-2.5">Тип</th>
<th className="text-left font-medium px-4 py-2.5">Действие</th>
<th className="text-right font-medium px-4 py-2.5">Маршрутов</th>
<th className="text-right font-medium px-4 py-2.5">Серверов</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody>
{filtered.map(c => (
<tr
key={c.id}
onClick={() => setSelected(c)}
className={cn(
"border-b last:border-0 cursor-pointer hover:bg-muted/40 transition-colors",
selected?.id === c.id && "bg-primary/5",
!c.enabled && "opacity-50",
)}
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<TagIcon className="size-3.5 text-muted-foreground shrink-0" />
<span className="font-mono text-xs font-medium bg-muted px-1.5 py-0.5 rounded">
{c.value}
</span>
<button
onClick={e => { e.stopPropagation(); handleCopy(c.value) }}
className="text-muted-foreground/40 hover:text-muted-foreground transition-colors"
>
{copied === c.value
? <CheckIcon className="size-3" />
: <CopyIcon className="size-3" />
}
</button>
</div>
</td>
<td className="px-4 py-2.5">
<p className="font-medium text-xs">{c.name}</p>
<p className="text-xs text-muted-foreground line-clamp-1">{c.description}</p>
</td>
<td className="px-4 py-2.5">
<span className="text-xs text-muted-foreground">{TYPE_LABELS[c.type]}</span>
</td>
<td className="px-4 py-2.5">
<span className={cn("text-xs font-medium", ACTION_COLOR[c.action])}>
{ACTION_LABELS[c.action]}{c.actionValue !== undefined ? ` ${c.actionValue}` : ""}
</span>
</td>
<td className="px-4 py-2.5 text-right font-mono text-xs tabular-nums">
{c.prefixCount.toLocaleString("ru-RU")}
</td>
<td className="px-4 py-2.5 text-right tabular-nums">
<div className="flex items-center justify-end gap-1">
<ServerIcon className="size-3 text-muted-foreground" />
<span className="font-mono text-xs">{c.serverCount.toLocaleString("ru-RU")}</span>
</div>
</td>
<td className="px-4 py-2.5">
<ChevronRightIcon className="size-4 text-muted-foreground/40" />
</td>
</tr>
))}
</tbody>
</table>
{filtered.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
<TagIcon className="size-8 opacity-30" />
<p className="text-sm">Ничего не найдено</p>
</div>
)}
</div>
</Card>
<DataPageCard>
<CommunitiesDataGrid
communities={filtered}
selectedId={selected?.id}
copiedValue={copied}
onSelect={setSelected}
onCopy={handleCopy}
/>
</DataPageCard>
</div>
{/* ── detail panel ── */}
+17 -130
View File
@@ -22,8 +22,10 @@ import type { PingProbe, Server, ServerStatus, ServerType } from "@/lib/data"
import type { GreTunnel } from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import { Flag } from "@/components/flag"
import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, FilterIcon, DownloadIcon } from "lucide-react"
import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, DownloadIcon } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { DataPageCard } from "@/components/data-page-card"
import { DashboardActiveProbesDataGrid } from "@/components/data-grids/dashboard-active-probes-data-grid"
import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client"
import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency"
@@ -72,11 +74,6 @@ function StatCard({
)
}
function formatLossPct(loss: number): string {
if (!Number.isFinite(loss)) return "—"
return Number.isInteger(loss) ? `${loss}%` : `${loss.toFixed(1)}%`
}
function fmtIntRu(n: number): string {
return n.toLocaleString("ru-RU")
}
@@ -114,22 +111,6 @@ function readMockDashboardStarIds(): Set<string> {
}
}
/** Совпадает с эталоном uptime / servers */
function TypeChip({ type }: { type: ServerType }) {
return (
<span className={cn(
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
type === "home-router"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: type === "jump-host"
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
)}>
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
</span>
)
}
interface BackendServerRow {
id: number
name: string
@@ -241,52 +222,6 @@ function mapBackendToServer(s: BackendServerRow): Server {
}
}
function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server[] }) {
const srv = catalog.find(s => s.id === probe.srcServerId)
const iface = (probe.srcInterface ?? "").trim() || "auto"
if (!srv) {
return (
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
<span className="mt-1 shrink-0 inline-flex">
<StatusDot status="offline" />
</span>
<div className="min-w-0">
<p className="text-[13px] font-medium text-muted-foreground truncate">
Сервер <span className="font-mono tabular-nums">{probe.srcServerId}</span>
</p>
<p className="text-[11px] font-mono text-muted-foreground truncate mt-0.5" title={iface}>
{iface}
</p>
</div>
</div>
)
}
return (
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
<span className="mt-1 shrink-0 inline-flex">
<StatusDot status={srv.status} pulse={srv.status === "online"} />
</span>
<div className="flex gap-2 min-w-0 flex-1">
<Flag code={srv.country} size={16} className="shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[13px] font-medium leading-tight truncate">{srv.name}</span>
<TypeChip type={srv.type} />
</div>
<p className="text-[11px] text-muted-foreground mt-0.5 truncate" title={`Интерфейс: ${iface}`}>
<span className="font-mono tabular-nums">{iface}</span>
{srv.site && srv.site !== "—" && (
<span className="text-muted-foreground/90"> · {srv.site}</span>
)}
</p>
</div>
</div>
</div>
)
}
export default function DashboardPage() {
const pathname = usePathname()
const { mode, backendUrl, prefsHydrated } = useDataSource()
@@ -691,7 +626,8 @@ export default function DashboardPage() {
}
}
const loadingBlock = probesLoading && liveProbes === null
const liveDataPending = liveServersResolved === null || liveProbes === null
const loadingBlock = liveDataPending || (probesLoading && liveProbes === null)
const srvList = liveServersResolved ?? []
const totalSrv = srvList.length
const onlineSrv = srvList.filter((s) => s.status === "online").length
@@ -1019,67 +955,18 @@ export default function DashboardPage() {
</div>
</CardHeader>
<CardContent className="pt-0 px-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-2.5 w-[min(280px,32vw)]">Источник</th>
<th className="text-left font-medium px-4 py-2.5">Проба</th>
<th className="text-left font-medium px-4 py-2.5">Цель</th>
<th className="text-left font-medium px-4 py-2.5">Фильтр</th>
<th className="text-right font-medium px-4 py-2.5">RTT</th>
<th className="text-right font-medium px-4 py-2.5">Потери</th>
<th className="text-left font-medium px-4 py-2.5 w-36">60с</th>
<th className="text-left font-medium px-4 py-2.5">Статус</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{isLive && probesLoading && liveProbes === null && (
<tr>
<td colSpan={8} className="px-5 py-6">
<div className="h-10 rounded-md bg-muted/50 animate-pulse max-w-md mx-auto" />
</td>
</tr>
)}
{!(isLive && probesLoading && liveProbes === null) && activeProbesTable.map((p) => {
const sparkColor = p.status === "down" ? "hsl(0 84% 60%)" : p.status === "warn" ? "hsl(32 94% 44%)" : "hsl(142 76% 36%)"
return (
<tr key={p.id} className="hover:bg-muted/40 transition-colors">
<td className="px-5 py-2.5 align-top">
<ProbeSourceCell probe={p} catalog={probeServerCatalog} />
</td>
<td className="px-4 py-2.5 font-medium">{p.name}</td>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{p.target}</td>
<td className="px-4 py-2.5">
<span className="inline-flex items-center gap-1 text-xs border border-border rounded px-2 py-0.5">
<FilterIcon className="size-3 text-muted-foreground" />{p.filter}
</span>
</td>
<td className="px-4 py-2.5 font-mono text-right">{p.rtt == null ? "—" : `${p.rtt} мс`}</td>
<td className={`px-4 py-2.5 font-mono text-right ${p.loss > 5 ? "text-red-500" : p.loss > 0 ? "text-amber-500" : "text-muted-foreground"}`}>
{formatLossPct(p.loss)}
</td>
<td className="px-4 py-2.5">
<Sparkline data={p.series} width={120} height={24} color={sparkColor} />
</td>
<td className="px-4 py-2.5">
<StatusBadge status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"} />
</td>
</tr>
)
})}
{!(isLive && probesLoading && liveProbes === null) && activeProbesTable.length === 0 && (
<tr>
<td colSpan={8} className="px-5 py-8 text-center text-sm text-muted-foreground">
{isLive && probesError
? "Нет данных о пробах. Проверьте сборщик uptime и настройки проб на странице мониторинга."
: "Нет проб с звездой на дашборде. Включите пробу и отметьте ★ в разделе «Мониторинг»."}
</td>
</tr>
)}
</tbody>
</table>
</div>
<DataPageCard className="rounded-none border-0 shadow-none">
<DashboardActiveProbesDataGrid
probes={activeProbesTable}
catalog={probeServerCatalog}
isLoading={isLive && probesLoading && liveProbes === null}
emptyDescription={
isLive && probesError
? "Нет данных о пробах. Проверьте сборщик uptime и настройки проб на странице мониторинга."
: "Нет проб с звездой на дашборде. Включите пробу и отметьте ★ в разделе «Мониторинг»."
}
/>
</DataPageCard>
</CardContent>
</Card>
File diff suppressed because it is too large Load Diff
+46 -56
View File
@@ -1,18 +1,24 @@
"use client"
import { useMemo } from "react"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { DataTable } from "@/components/data-table"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { DomainsDataGrid } from "@/components/data-grids/domains-data-grid"
import { FileImportDialog } from "@/components/file-import-dialog"
import { domains as mockDomains } from "@/lib/data"
import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
export default function DomainsPage() {
const { mode } = useDataSource()
const { enabled, snapshot, loading, error } = useEvoBGP()
const [importOpen, setImportOpen] = useState(false)
const [search, setSearch] = useState("")
const useEvoCatalog = mode === "live" && enabled
@@ -22,13 +28,26 @@ export default function DomainsPage() {
return snapshot?.domains ?? []
}, [useEvoCatalog, loading, snapshot])
const filtered = useMemo(() => {
if (!search) return rows
const q = search.toLowerCase()
return rows.filter(
(r) =>
r.domain.toLowerCase().includes(q) ||
r.asn.toLowerCase().includes(q) ||
r.filter.toLowerCase().includes(q),
)
}, [rows, search])
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
actions={
<>
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />Импорт
</Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
<Button size="sm"><PlusIcon className="size-4" />Добавить домен</Button>
</>
@@ -55,60 +74,31 @@ export default function DomainsPage() {
</p>
)}
</div>
<DataTable
data={rows}
searchPlaceholder="Поиск по домену…"
searchKeys={["domain", "asn", "filter"]}
columns={[
{
key: "domain",
label: "Домен",
render: (d) => <span className="font-medium">{d.domain}</span>,
},
{
key: "resolvedIp",
label: "Resolved IP",
render: (d) => <span className="font-mono text-xs text-muted-foreground">{d.resolvedIp}</span>,
},
{
key: "asn",
label: "ASN",
render: (d) => <span className="font-mono text-xs">{d.asn}</span>,
},
{
key: "purpose",
label: "Назначение",
render: (d) => (
<span className="text-xs border border-border rounded px-2 py-0.5">{d.purpose}</span>
),
},
{
key: "filter",
label: "Фильтр",
render: (d) => (
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
</span>
),
},
{
key: "updated",
label: "Обновлён",
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
},
{
key: "enabled",
label: "Статус",
render: (d) => (
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
{d.enabled ? "Активен" : "Отключён"}
</span>
),
},
]}
/>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по домену…"
countLabel={`${filtered.length} доменов`}
/>
<DomainsDataGrid
domains={filtered}
isLoading={useEvoCatalog && loading && !snapshot}
pagination={useEvoCatalog}
/>
</DataPageCard>
</div>
</div>
<FileImportDialog
open={importOpen}
onOpenChange={setImportOpen}
title="Импорт доменов"
description="Загрузите CSV или JSON со списком доменов"
accept=".csv,.json,text/csv,application/json"
onImport={async (files) => {
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
}}
/>
</div>
)
}
+51 -325
View File
@@ -2,8 +2,14 @@
import { useMemo, useState, useCallback, useEffect } from "react"
import { PageHeader } from "@/components/page-header"
import { EmptyState } from "@/components/empty-state"
import { StatusDot } from "@/components/status-dot"
import { Flag } from "@/components/flag"
import {
FiltersDataGrid,
type RecursiveRouteLite,
} from "@/components/data-grids/filters-data-grid"
import { DataPageCard } from "@/components/data-page-card"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -35,42 +41,6 @@ import { toast } from "sonner"
type FilterRouterSyncStatus = "synced" | "drift" | "missing"
function RouterSyncMarker({
status,
}: {
status: FilterRouterSyncStatus | null | "skip"
}) {
if (status === "skip") {
return <span className="size-3.5 shrink-0 block" aria-hidden />
}
const icon =
status === "synced"
? <CheckCircle2Icon className="size-3.5 text-emerald-600 dark:text-emerald-500 shrink-0" />
: status === "drift"
? <AlertTriangleIcon className="size-3.5 text-amber-500 shrink-0" />
: status === "missing"
? <XCircleIcon className="size-3.5 text-destructive shrink-0" />
: <CircleDashedIcon className="size-3.5 text-muted-foreground/35 shrink-0" />
const title =
status === "synced"
? "Совпадает с цепочкой bgp-in на MikroTik"
: status === "drift"
? "В БД и на роутере разное действие (gateway, blackhole или out-interface)"
: status === "missing"
? "Эта community не найдена в правиле bgp-in на роутере"
: "Не проверено — нажмите «Сверить с роутером»"
return (
<Tooltip>
<TooltipTrigger className="inline-flex cursor-default border-0 bg-transparent p-0">
{icon}
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{title}
</TooltipContent>
</Tooltip>
)
}
function newId() { return `r${Date.now()}-${Math.random().toString(36).slice(2, 6)}` }
function innerIpToGateway(ip: string) { return ip.split("/")[0] }
@@ -137,16 +107,6 @@ function dedupeRecursiveRoutesByDstAddress(routes: RecursiveRouteLite[]): Recurs
})
}
interface RecursiveRouteLite {
id: string
dstAddress: string
gateway: string
distance: number
routingTable: string
comment: string
disabled: boolean
}
const COMMUNITY_NAMES: Record<string, string> = {
"65001:100": "youtube-bypass",
"65001:200": "streaming-eu",
@@ -478,171 +438,6 @@ function CommunityInput({
)
}
// ── filter rule row ────────────────────────────────────────────────────────────
function FilterRow({
rule, index, isLast, onEdit, onDelete, onMoveUp, onMoveDown, tunnelsList, serversList,
communityNameMap,
recursiveRoutes,
routerSyncStatus,
}: {
rule: FilterRule; index: number; isLast: boolean
onEdit: () => void; onDelete: () => void; onMoveUp: () => void; onMoveDown: () => void
tunnelsList: GreTunnel[]
serversList: Server[]
communityNameMap: Record<string, string>
recursiveRoutes: RecursiveRouteLite[]
routerSyncStatus?: FilterRouterSyncStatus | null | "skip"
}) {
const [confirmDel, setConfirmDel] = useState(false)
const isBlackhole = rule.action === "blackhole"
const isRecRef = !isBlackhole && isRecursiveGatewayRef(rule.gatewayTunnelId)
const recRowByRef = isRecRef ? recursiveRoutes.find(r => r.id === rule.gatewayTunnelId.slice(4)) : undefined
const recRowByHop =
!isBlackhole && !(rule.gatewayTunnelId ?? "").trim() && rule.gateway.trim()
? pickRecursiveRouteByGatewayHop(recursiveRoutes, rule.gateway)
: undefined
const recRow = recRowByRef ?? recRowByHop
const treatAsRecursive =
!isBlackhole && (isRecRef || !!recRowByHop)
const tunnel = !isBlackhole && !treatAsRecursive
? tunnelsList.find(t => t.id === rule.gatewayTunnelId)
: undefined
const remoteSrv = tunnel ? serversList.find(s => s.host === tunnel.remoteAddress) : undefined
const communityName = communityNameMap[rule.community] ?? rule.communityName
return (
<div className={cn(
"group grid items-center gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors",
"grid-cols-[20px_20px_22px_1fr_1fr_1fr_64px]",
isBlackhole && "bg-red-500/[0.03]",
)}>
{/* priority */}
<div className="flex items-center justify-center text-[11px] font-mono text-muted-foreground/40 select-none">
{isLast
? <StarIcon className="size-3 text-amber-400 fill-amber-400" aria-label="Наивысший приоритет" />
: <span>{index + 1}</span>
}
</div>
{/* reorder */}
<div className="flex flex-col gap-px opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={onMoveUp} disabled={index === 0}
className="text-muted-foreground/50 hover:text-foreground disabled:opacity-20 transition-colors">
<ChevronUpIcon className="size-3" />
</button>
<button onClick={onMoveDown} disabled={isLast}
className="text-muted-foreground/50 hover:text-foreground disabled:opacity-20 transition-colors">
<ChevronDownIcon className="size-3" />
</button>
</div>
{/* MikroTik sync marker */}
<div className="flex items-center justify-center">
<RouterSyncMarker
status={routerSyncStatus === undefined ? "skip" : routerSyncStatus}
/>
</div>
{/* community */}
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className={cn(
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border font-mono",
isBlackhole
? "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/25"
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
)}>
{rule.community}
</span>
{isBlackhole && (
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold border
bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20 uppercase tracking-wide">
blackhole
</span>
)}
</div>
{communityName && (
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">{communityName}</p>
)}
</div>
{/* gateway / tunnel — or blackhole target */}
<div className="min-w-0 flex flex-col gap-0.5">
{isBlackhole ? (
<div className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full shrink-0 bg-red-500 animate-pulse" />
<span className="font-mono text-xs font-medium text-red-600 dark:text-red-400">type=blackhole</span>
</div>
) : (
<>
<div className="flex items-center gap-1.5 flex-wrap">
<span className={cn(
"size-1.5 rounded-full shrink-0",
treatAsRecursive
? "bg-sky-500"
: tunnel?.status === "up"
? "bg-[var(--status-online)]"
: tunnel?.status === "degraded"
? "bg-[var(--status-degraded)]"
: "bg-[var(--status-offline)]",
)} />
{treatAsRecursive ? (
<>
<RouteIcon className="size-3 text-muted-foreground shrink-0" />
<span className="font-mono text-xs font-medium">
{recRow ? gatewayFromRecursiveDst(recRow.dstAddress) : rule.gateway}
</span>
<span className="text-[10px] font-medium text-muted-foreground border border-border rounded px-1 uppercase tracking-wide">
recursive
</span>
</>
) : (
<>
{remoteSrv && <Flag code={remoteSrv.country} />}
<span className="font-mono text-xs font-medium">{rule.gateway}</span>
</>
)}
</div>
{treatAsRecursive ? (
recRow ? (
<p className="text-[11px] text-muted-foreground truncate pl-3">{recRow.dstAddress}</p>
) : isRecRef ? (
<p className="text-[11px] text-amber-600 dark:text-amber-400 truncate pl-3">
рекурсивный маршрут (нет строки в списке синхронизируйте «Рекурсивные маршруты»)
</p>
) : null
) : tunnel ? (
<p className="text-[11px] text-muted-foreground truncate pl-3">{tunnel.name}</p>
) : null}
</>
)}
</div>
{/* description */}
<p className="text-xs text-muted-foreground truncate">{rule.description || "—"}</p>
{/* actions */}
<div className="flex items-center gap-0.5 justify-end opacity-0 group-hover:opacity-100 transition-opacity">
<Button size="sm" variant="ghost" className="size-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onEdit}>
<PencilIcon className="size-3.5" />
</Button>
<Button size="sm" variant="ghost"
className={cn("size-7 p-0 transition-colors",
confirmDel
? "text-destructive bg-destructive/10 hover:bg-destructive/20"
: "text-muted-foreground hover:text-destructive",
)}
onClick={() => { if (!confirmDel) setConfirmDel(true); else onDelete() }}
onBlur={() => setConfirmDel(false)}>
{confirmDel ? <AlertCircleIcon className="size-3.5" /> : <TrashIcon className="size-3.5" />}
</Button>
</div>
</div>
)
}
// ── rule sheet ─────────────────────────────────────────────────────────────────
interface RuleForm {
@@ -1496,8 +1291,6 @@ function CopyRulesSheet({
// ── page ───────────────────────────────────────────────────────────────────────
type SortKey = "community" | "gateway" | "description"
interface BackendServer {
id: number
name: string
@@ -1529,11 +1322,6 @@ function makeApiFetch(backendUrl: string) {
}
}
function SortIndicator({ k, sortKey, sortAsc }: { k: SortKey; sortKey: SortKey; sortAsc: boolean }) {
if (sortKey !== k) return <ArrowUpDownIcon className="size-3 opacity-30" />
return sortAsc ? <ArrowUpIcon className="size-3" /> : <ArrowDownIcon className="size-3" />
}
export default function FiltersPage() {
const { mode, backendUrl } = useDataSource()
const evo = useEvoBGP()
@@ -1601,8 +1389,6 @@ export default function FiltersPage() {
}, [isLive, apiFetch])
const [selectedServerId, setSelectedServerId] = useState("srv1")
const [search, setSearch] = useState("")
const [sortKey, setSortKey] = useState<SortKey>("community")
const [sortAsc, setSortAsc] = useState(true)
const [sheetOpen, setSheetOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
const [sheetInitial, setSheetInitial]= useState<RuleForm>(emptyForm())
@@ -1707,24 +1493,14 @@ export default function FiltersPage() {
const filteredRules = useMemo(() => {
const q = search.toLowerCase()
const list = q
? currentRules.filter(r =>
r.community.includes(q) ||
(communityNameMap[r.community] ?? "").toLowerCase().includes(q) ||
r.gateway.includes(q) ||
r.description.toLowerCase().includes(q)
)
: [...currentRules]
if (search) {
const mult = sortAsc ? 1 : -1
list.sort((a, b) => {
if (sortKey === "community") return mult * a.community.localeCompare(b.community)
if (sortKey === "gateway") return mult * a.gateway.localeCompare(b.gateway)
return mult * a.description.localeCompare(b.description)
})
}
return list
}, [currentRules, search, sortKey, sortAsc, communityNameMap])
if (!q) return currentRules
return currentRules.filter((r) =>
r.community.includes(q) ||
(communityNameMap[r.community] ?? "").toLowerCase().includes(q) ||
r.gateway.includes(q) ||
r.description.toLowerCase().includes(q),
)
}, [currentRules, search, communityNameMap])
const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => {
setRouterCompare(rc => (rc && rc.serverId === serverId ? null : rc))
@@ -1842,10 +1618,6 @@ export default function FiltersPage() {
})
}
const toggleSort = (k: SortKey) => {
if (sortKey === k) setSortAsc(v => !v); else { setSortKey(k); setSortAsc(true) }
}
if (!selectedServer) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
@@ -2030,7 +1802,7 @@ export default function FiltersPage() {
)
})()}
<Card className="overflow-hidden py-0 gap-0">
<DataPageCard>
{/* selected server header */}
<div className="flex items-center gap-2.5 px-4 py-3 border-b bg-muted/10">
@@ -2062,22 +1834,23 @@ export default function FiltersPage() {
{currentRules.length === 0 ? (
/* empty state */
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
<NetworkIcon className="size-8 text-muted-foreground/20" />
<div>
<p className="text-sm font-medium text-muted-foreground">Нет правил фильтрации</p>
<p className="text-xs text-muted-foreground/60 mt-1">
{(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId))
? "Добавьте правило: BGP community → GRE-шлюз"
: "Сначала добавьте GRE-туннели для этого сервера"}
</p>
</div>
{(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId)) && (
<Button size="sm" onClick={openCreate}>
<PlusIcon className="size-4" />Добавить правило
</Button>
)}
</div>
<EmptyState
icon={<NetworkIcon className="size-4" />}
title="Нет правил фильтрации"
description={
(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId))
? "Добавьте правило: BGP community → GRE-шлюз"
: "Сначала добавьте GRE-туннели для этого сервера"
}
action={
(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId)) ? (
<Button size="sm" onClick={openCreate}>
<PlusIcon className="size-4" />Добавить правило
</Button>
) : undefined
}
className="py-20"
/>
) : filteredRules.length === 0 ? (
/* no search results */
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
@@ -2085,71 +1858,24 @@ export default function FiltersPage() {
<p className="text-sm">Ничего не найдено</p>
</div>
) : (
<>
{/* table header with sort */}
<div className={cn(
"grid items-center gap-3 px-4 py-1.5 border-b bg-muted/30",
"grid-cols-[20px_20px_22px_1fr_1fr_1fr_64px]",
"text-[10px] font-semibold uppercase tracking-widest text-muted-foreground",
)}>
<span>#</span>
<span />
<Tooltip>
<TooltipTrigger className="cursor-help text-center font-mono normal-case tracking-normal border-0 bg-transparent p-0 w-full">
MT
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
Совпадение с MikroTik (bgp-in): нажмите «Сверить с роутером»
</TooltipContent>
</Tooltip>
<button onClick={() => toggleSort("community")}
className="flex items-center gap-1 hover:text-foreground text-left transition-colors">
Community <SortIndicator k="community" sortKey={sortKey} sortAsc={sortAsc} />
</button>
<button onClick={() => toggleSort("gateway")}
className="flex items-center gap-1 hover:text-foreground text-left transition-colors">
Gateway <SortIndicator k="gateway" sortKey={sortKey} sortAsc={sortAsc} />
</button>
<button onClick={() => toggleSort("description")}
className="flex items-center gap-1 hover:text-foreground text-left transition-colors">
Описание <SortIndicator k="description" sortKey={sortKey} sortAsc={sortAsc} />
</button>
<span />
</div>
{/* rows */}
<div className="divide-y divide-border/60">
{(search ? filteredRules : currentRules).map((rule, i, arr) => (
<FilterRow
key={rule.id}
rule={rule}
index={i}
isLast={i === arr.length - 1}
tunnelsList={allTunnels}
serversList={allServers}
communityNameMap={communityNameMap}
recursiveRoutes={recRoutesByServer[selectedServerId] ?? []}
routerSyncStatus={
!isLive
? undefined
: !routerCompare || routerCompare.serverId !== selectedServerId
? null
: routerCompare.byCommunity[rule.community.trim()] ?? null
}
onEdit={() => openEdit(rule)}
onDelete={() => handleDelete(rule.id)}
onMoveUp={() => handleMoveUp(currentRules.findIndex(r => r.id === rule.id))}
onMoveDown={() => handleMoveDown(currentRules.findIndex(r => r.id === rule.id))}
/>
))}
</div>
{/* footer hint */}
<div className="px-4 py-2 text-[11px] text-muted-foreground/40 flex items-center gap-1.5 border-t">
<StarIcon className="size-3 text-amber-400 fill-amber-400 shrink-0" />
Последнее правило имеет наивысший приоритет в RouterOS
</div>
</>
<FiltersDataGrid
rules={search ? filteredRules : currentRules}
tunnelsList={allTunnels}
serversList={allServers}
communityNameMap={communityNameMap}
recursiveRoutes={recRoutesByServer[selectedServerId] ?? []}
routerSyncByCommunity={
!isLive || !routerCompare || routerCompare.serverId !== selectedServerId
? null
: routerCompare.byCommunity
}
isLive={isLive}
enableSorting={!!search}
onEdit={openEdit}
onDelete={handleDelete}
onMoveUp={(id) => handleMoveUp(currentRules.findIndex((r) => r.id === id))}
onMoveDown={(id) => handleMoveDown(currentRules.findIndex((r) => r.id === id))}
/>
)}
{/* add rule shortcut */}
@@ -2158,7 +1884,7 @@ export default function FiltersPage() {
<PlusIcon className="size-3.5" />
Добавить правило для {selectedServer.name}
</button>
</Card>
</DataPageCard>
</div>
</div>
+68 -255
View File
@@ -2,6 +2,14 @@
import { useEffect, useMemo, useRef, useState } from "react"
import { PageHeader } from "@/components/page-header"
import {
FirewallRulesDataGrid,
ActionBadge,
ChainBadge,
} from "@/components/data-grids/firewall-rules-data-grid"
import { FirewallScenarioRulesDataGrid } from "@/components/data-grids/firewall-scenario-rules-data-grid"
import { DataPageCard } from "@/components/data-page-card"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { firewallRules, type FirewallRule } from "@/lib/data"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
@@ -341,62 +349,7 @@ function fmtHits(n: number): string {
return String(n)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button type="button" role="switch" aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
checked ? "bg-primary" : "bg-input",
)}>
<span className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0",
)} />
</button>
)
}
function ActionBadge({ action }: { action: string }) {
const cls = ACTION_STYLES[action] ?? "bg-muted text-muted-foreground border-border"
return (
<span className={cn("text-[11px] font-mono font-medium px-2 py-0.5 rounded border whitespace-nowrap", cls)}>
{action}
</span>
)
}
function ChainBadge({ chain }: { chain: string }) {
const cls = CHAIN_STYLES[chain] ?? "bg-muted text-muted-foreground"
return (
<span className={cn("text-[11px] font-mono px-2 py-0.5 rounded", cls)}>
{chain}
</span>
)
}
function Field({ label, hint, required, children }: {
label: string; hint?: string; required?: boolean; children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 py-0.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
// ActionBadge, ChainBadge — из firewall-rules-data-grid
function NativeSelect({ value, onChange, children, className }: {
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
@@ -507,67 +460,67 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
<div className="flex flex-col gap-4">
<SectionTitle>Цепочка и действие</SectionTitle>
<div className="grid grid-cols-2 gap-3">
<Field label="Цепочка" required>
<FormField label="Цепочка" required>
<NativeSelect value={form.chain} onChange={(v) => set("chain", v)}>
{chainsForGroup.map((c) => <option key={c} value={c}>{c}</option>)}
</NativeSelect>
</Field>
<Field label="Действие" required>
</FormField>
<FormField label="Действие" required>
<NativeSelect value={form.action} onChange={(v) => set("action", v)}>
{actions.map((a) => <option key={a} value={a}>{a}</option>)}
</NativeSelect>
</Field>
</FormField>
</div>
</div>
{/* Matching */}
<div className="flex flex-col gap-4">
<SectionTitle>Условие совпадения</SectionTitle>
<Field label="Протокол">
<FormField label="Протокол">
<NativeSelect value={form.proto} onChange={(v) => set("proto", v)}>
{["all","tcp","udp","icmp","gre","esp","ah","ipencap","ospf"].map((p) =>
<option key={p} value={p}>{p}</option>
)}
</NativeSelect>
</Field>
</FormField>
<div className="grid grid-cols-2 gap-3">
<Field label="Src-address / Address-list" hint="IP, CIDR или имя address-list">
<FormField label="Src-address / Address-list" hint="IP, CIDR или имя address-list">
<Input className="font-mono h-8" placeholder="10.0.0.0/8"
value={form.srcAddrList} onChange={(e) => set("srcAddrList", e.target.value)} />
</Field>
<Field label="Dst-address / Address-list">
</FormField>
<FormField label="Dst-address / Address-list">
<Input className="font-mono h-8" placeholder="0.0.0.0/0"
value={form.dstAddrList} onChange={(e) => set("dstAddrList", e.target.value)} />
</Field>
</FormField>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Src-port" hint="TCP/UDP, напр. 1024-65535">
<FormField label="Src-port" hint="TCP/UDP, напр. 1024-65535">
<Input className="font-mono h-8" placeholder="—"
value={form.srcPort} onChange={(e) => set("srcPort", e.target.value)} />
</Field>
<Field label="Dst-port">
</FormField>
<FormField label="Dst-port">
<Input className="font-mono h-8" placeholder="443"
value={form.dstPort} onChange={(e) => set("dstPort", e.target.value)} />
</Field>
</FormField>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="In-interface" hint="Входящий интерфейс">
<FormField label="In-interface" hint="Входящий интерфейс">
<Input className="font-mono h-8" placeholder="wan-msk"
value={form.inIface} onChange={(e) => set("inIface", e.target.value)} />
</Field>
<Field label="Out-interface">
</FormField>
<FormField label="Out-interface">
<Input className="font-mono h-8" placeholder="lan"
value={form.outIface} onChange={(e) => set("outIface", e.target.value)} />
</Field>
</FormField>
</div>
<Field label="Connection-state" hint="Через запятую: new, established, related, invalid">
<FormField label="Connection-state" hint="Через запятую: new, established, related, invalid">
<Input className="font-mono h-8" placeholder="new,established"
value={form.connState} onChange={(e) => set("connState", e.target.value)} />
</Field>
</FormField>
</div>
{/* Log + Comment */}
@@ -578,18 +531,18 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
<p className="text-sm font-medium">Log</p>
<p className="text-xs text-muted-foreground">Записывать совпадения в системный лог</p>
</div>
<Toggle checked={form.log} onChange={(v) => set("log", v)} />
<FormToggle checked={form.log} onChange={(v) => set("log", v)} />
</div>
{form.log && (
<Field label="Log-prefix" hint="Метка в логе, например FW-DROP">
<FormField label="Log-prefix" hint="Метка в логе, например FW-DROP">
<Input className="font-mono h-8" placeholder="FW-RULE"
value={form.logPrefix} onChange={(e) => set("logPrefix", e.target.value)} />
</Field>
</FormField>
)}
<Field label="Комментарий">
<FormField label="Комментарий">
<Input className="h-8" placeholder="Описание правила"
value={form.comment} onChange={(e) => set("comment", e.target.value)} />
</Field>
</FormField>
</div>
{/* Enabled */}
@@ -598,7 +551,7 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
<p className="text-sm font-medium">Правило включено</p>
<p className="text-xs text-muted-foreground">Отключённые правила сохраняются, но не применяются</p>
</div>
<Toggle checked={form.enabled} onChange={(v) => set("enabled", v)} />
<FormToggle checked={form.enabled} onChange={(v) => set("enabled", v)} />
</div>
{/* CLI preview */}
@@ -1039,14 +992,14 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
<div className="flex flex-col gap-3">
<SectionTitle>Название</SectionTitle>
<div className="grid grid-cols-2 gap-3">
<Field label="Название сценария" required>
<FormField label="Название сценария" required>
<Input className="h-8" placeholder="Блокировка Tor Exit"
value={name} onChange={e => setName(e.target.value)} />
</Field>
<Field label="Описание">
</FormField>
<FormField label="Описание">
<Input className="h-8" placeholder="Краткое описание"
value={desc} onChange={e => setDesc(e.target.value)} />
</Field>
</FormField>
</div>
</div>
@@ -1054,7 +1007,7 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
<div className="flex flex-col gap-3">
<SectionTitle>Тестовый пакет по умолчанию</SectionTitle>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<Field label="Направление / цепочка">
<FormField label="Направление / цепочка">
<NativeSelect value={pkt.chain} onChange={v => setP("chain", v)}>
<optgroup label="Полный маршрут">
<option value="forward">forward транзит</option>
@@ -1067,40 +1020,40 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
.map(c => <option key={c} value={c}>{c}</option>)}
</optgroup>
</NativeSelect>
</Field>
<Field label="Протокол">
</FormField>
<FormField label="Протокол">
<NativeSelect value={pkt.proto} onChange={v => setP("proto", v)}>
{PROTO_OPTS.map(p => <option key={p} value={p}>{p}</option>)}
</NativeSelect>
</Field>
<Field label="Conn-state">
</FormField>
<FormField label="Conn-state">
<Input className="font-mono h-8" value={pkt.connState}
placeholder="new" onChange={e => setP("connState", e.target.value)} />
</Field>
<Field label="Src IP">
</FormField>
<FormField label="Src IP">
<Input className="font-mono h-8" value={pkt.srcAddr}
onChange={e => setP("srcAddr", e.target.value)} />
</Field>
<Field label="Dst IP">
</FormField>
<FormField label="Dst IP">
<Input className="font-mono h-8" value={pkt.dstAddr}
onChange={e => setP("dstAddr", e.target.value)} />
</Field>
<Field label="Dst Port">
</FormField>
<FormField label="Dst Port">
<Input className="font-mono h-8" value={pkt.dstPort}
placeholder="443" onChange={e => setP("dstPort", e.target.value)} />
</Field>
<Field label="In-interface">
</FormField>
<FormField label="In-interface">
<Input className="font-mono h-8" value={pkt.inIface}
placeholder="lan" onChange={e => setP("inIface", e.target.value)} />
</Field>
<Field label="Out-interface">
</FormField>
<FormField label="Out-interface">
<Input className="font-mono h-8" value={pkt.outIface}
placeholder="wan-msk" onChange={e => setP("outIface", e.target.value)} />
</Field>
<Field label="Dst addr-list">
</FormField>
<FormField label="Dst addr-list">
<Input className="font-mono h-8" value={pkt.dstAddrList}
placeholder="" onChange={e => setP("dstAddrList", e.target.value)} />
</Field>
</FormField>
</div>
</div>
@@ -1170,7 +1123,7 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Toggle checked={addForm.enabled} onChange={v => setAF("enabled", v)} />
<FormToggle checked={addForm.enabled} onChange={v => setAF("enabled", v)} />
<span className="text-xs text-muted-foreground">Включено</span>
</div>
<Button size="sm" onClick={addRule}><PlusIcon className="size-4" />Добавить</Button>
@@ -1181,55 +1134,13 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
{/* Rules table */}
{rules.length > 0 ? (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/30 text-muted-foreground">
<th className="text-left px-3 py-2 w-6">#</th>
<th className="text-left px-3 py-2">Цепочка</th>
<th className="text-left px-3 py-2">Действие</th>
<th className="text-left px-3 py-2">Src</th>
<th className="text-left px-3 py-2">Dst</th>
<th className="text-left px-3 py-2">Порт</th>
<th className="text-left px-3 py-2">Iface</th>
<th className="text-left px-3 py-2">Комментарий</th>
<th className="w-24 px-2 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{rules.map((r, i) => (
<tr key={r.id} className={cn(
"hover:bg-muted/20 transition-colors",
!r.enabled && "opacity-40",
)}>
<td className="px-3 py-1.5 text-muted-foreground tabular-nums">{i + 1}</td>
<td className="px-3 py-1.5"><ChainBadge chain={r.chain} /></td>
<td className="px-3 py-1.5"><ActionBadge action={r.action} /></td>
<td className="px-3 py-1.5 font-mono text-muted-foreground max-w-[90px] truncate">{r.src || "any"}</td>
<td className="px-3 py-1.5 font-mono text-muted-foreground max-w-[90px] truncate">{r.dst || "any"}</td>
<td className="px-3 py-1.5 font-mono text-muted-foreground">{r.port || "—"}</td>
<td className="px-3 py-1.5 font-mono text-muted-foreground">{r.iface || "—"}</td>
<td className="px-3 py-1.5 text-muted-foreground/70 max-w-[110px] truncate">{r.comment || "—"}</td>
<td className="px-2 py-1.5">
<div className="flex items-center gap-0.5 justify-end">
<button type="button" onClick={() => toggleEnabled(r.id)}
title={r.enabled ? "Отключить" : "Включить"}
className="p-0.5 text-muted-foreground/40 hover:text-foreground transition-colors">
<PowerIcon className="size-3.5" />
</button>
<button type="button" onClick={() => moveRule(r.id, -1)} disabled={i === 0}
className="p-0.5 text-muted-foreground/40 hover:text-foreground disabled:opacity-20 transition-colors"></button>
<button type="button" onClick={() => moveRule(r.id, 1)} disabled={i === rules.length - 1}
className="p-0.5 text-muted-foreground/40 hover:text-foreground disabled:opacity-20 transition-colors"></button>
<button type="button" onClick={() => removeRule(r.id)}
className="p-0.5 ml-0.5 text-muted-foreground/40 hover:text-red-500 transition-colors">
<Trash2Icon className="size-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
<FirewallScenarioRulesDataGrid
rules={rules}
onToggleEnabled={toggleEnabled}
onMoveUp={(id) => moveRule(id, -1)}
onMoveDown={(id) => moveRule(id, 1)}
onRemove={removeRule}
/>
</div>
) : (
!addOpen && (
@@ -1721,104 +1632,6 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
)
}
// ─── Rules Table ──────────────────────────────────────────────────────────────
function RulesTable({ rules, onToggle, onEdit }: {
rules: FirewallRule[]
onToggle: (id: string) => void
onEdit: (r: FirewallRule) => void
}) {
if (rules.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<ShieldOffIcon className="size-10 mb-3 opacity-30" />
<p className="text-sm font-medium">Правила не найдены</p>
<p className="text-xs mt-1">Попробуйте изменить фильтр или добавьте новое правило</p>
</div>
)
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3 w-8">#</th>
<th className="text-left font-medium px-4 py-3">Цепочка</th>
<th className="text-left font-medium px-4 py-3">Действие</th>
<th className="text-left font-medium px-4 py-3">Источник</th>
<th className="text-left font-medium px-4 py-3">Назначение</th>
<th className="text-left font-medium px-4 py-3">Протокол</th>
<th className="text-left font-medium px-4 py-3">Порт</th>
<th className="text-left font-medium px-4 py-3">Интерфейс</th>
<th className="text-right font-medium px-4 py-3">Пакетов</th>
<th className="text-left font-medium px-4 py-3 w-12">Вкл</th>
<th className="w-10 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{rules.map((r, i) => (
<tr key={r.id}
className={cn("hover:bg-muted/40 transition-colors", !r.enabled && "opacity-40")}>
<td className="px-5 py-2.5 font-mono text-xs text-muted-foreground">{i + 1}</td>
<td className="px-4 py-2.5"><ChainBadge chain={r.chain} /></td>
<td className="px-4 py-2.5"><ActionBadge action={r.action} /></td>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground max-w-[140px] truncate">
{r.src || "any"}
</td>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground max-w-[140px] truncate">
{r.dst || "any"}
</td>
<td className="px-4 py-2.5 text-xs font-mono">{r.proto}</td>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{r.port || "—"}</td>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{r.iface || "—"}</td>
<td className="px-4 py-2.5 text-right">
<span className={cn(
"text-xs font-mono tabular-nums",
r.hits > 1_000_000 ? "text-emerald-600 dark:text-emerald-400 font-semibold"
: r.hits > 10_000 ? "text-foreground"
: "text-muted-foreground",
)}>
{fmtHits(r.hits)}
</span>
</td>
<td className="px-4 py-2.5">
<Toggle checked={r.enabled} onChange={() => onToggle(r.id)} />
</td>
<td className="px-3 py-2.5">
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7">
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={() => onEdit(r)}>
<PencilIcon className="size-4" />Редактировать
</DropdownMenuItem>
<DropdownMenuItem>
<CopyIcon className="size-4" />Дублировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onToggle(r.id)}>
<PowerIcon className="size-4" />
{r.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" />Удалить правило
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// ════════════════════════════════════════════════════════════════════════════
export default function FirewallPage() {
const [rules, setRules] = useState<FirewallRule[]>(firewallRules)
@@ -1970,7 +1783,7 @@ export default function FirewallPage() {
) : chainGroup === "simulator" ? (
<SimulatorTab rules={rules} />
) : (
<Card>
<DataPageCard>
{/* toolbar */}
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
{/* IP family selector */}
@@ -2018,8 +1831,8 @@ export default function FirewallPage() {
<span className="text-sm text-muted-foreground ml-auto">{filteredRules.length} правил</span>
</div>
<RulesTable rules={filteredRules} onToggle={toggleRule} onEdit={openEdit} />
</Card>
<FirewallRulesDataGrid rules={filteredRules} onToggle={toggleRule} onEdit={openEdit} />
</DataPageCard>
)}
{/* RouterOS reference */}
+61 -261
View File
@@ -2,6 +2,10 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card"
import { GreTunnelsDataGrid } from "@/components/data-grids/gre-tunnels-data-grid"
import { GrePoolsDataGrid } from "@/components/data-grids/gre-pools-data-grid"
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data"
import type { GrePool, GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion, Server } from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
@@ -138,58 +142,6 @@ function IpsecBadge({ secured }: { secured: boolean }) {
)
}
function Field({ label, hint, required, children }: {
label: string; hint?: string; required?: boolean; children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors ${checked ? "bg-primary" : "bg-input"}`}
>
<span className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-4" : "translate-x-0"}`} />
</button>
)
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 py-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
function SegmentedControl<T extends string>({ value, onChange, options }: {
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
}) {
return (
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
{options.map((o) => (
<button key={o.value} type="button" onClick={() => onChange(o.value)}
className={`px-3 py-1 text-sm rounded transition-colors ${value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
{o.label}
</button>
))}
</div>
)
}
// ─── Live API (как на /filters) ─────────────────────────────────────────────
interface BackendServer {
@@ -501,7 +453,7 @@ export default function GrePage() {
{/* ── Tunnels ── */}
{pageTab === "tunnels" && (
<Card>
<DataPageCard>
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{tunnelTabs.map((t) => (
@@ -520,177 +472,25 @@ export default function GrePage() {
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} туннелей</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3">Интерфейс / Сервер</th>
<th className="text-left font-medium px-4 py-3">Эндпоинты</th>
<th className="text-left font-medium px-4 py-3">Внутренний IP</th>
<th className="text-left font-medium px-4 py-3">Пул</th>
<th className="text-left font-medium px-4 py-3">IPsec</th>
<th className="text-left font-medium px-4 py-3">Шифрование</th>
<th className="text-center font-medium px-4 py-3">MTU</th>
<th className="text-left font-medium px-4 py-3">Keepalive</th>
<th className="text-left font-medium px-4 py-3">Статус</th>
<th className="w-20 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((t, index) => {
const srv = serverById[t.serverId]
const pool = poolById[t.poolId]
return (
<tr key={`${t.id}:${t.serverId}:${t.name}:${index}`} className="hover:bg-muted/40 transition-colors">
<td className="px-5 py-3">
<p className="font-medium font-mono text-[13px]">{t.name}</p>
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
{srv && <Flag code={srv.country} />}
{srv?.name ?? t.serverId}
</p>
</td>
<td className="px-4 py-3">
<p className="font-mono text-xs">
{t.localAddress === "0.0.0.0" ? <span className="text-muted-foreground">авто</span> : t.localAddress}
</p>
<p className="font-mono text-xs text-muted-foreground"> {t.remoteAddress}</p>
</td>
<td className="px-4 py-3">
<p className="font-mono text-xs">{t.localInnerIp}</p>
<p className="font-mono text-xs text-muted-foreground">{t.remoteInnerIp}</p>
</td>
<td className="px-4 py-3">
<span className="text-xs text-muted-foreground font-mono">{pool?.name ?? "—"}</span>
</td>
<td className="px-4 py-3"><IpsecBadge secured={!!t.ipsec} /></td>
<td className="px-4 py-3">
{t.ipsec ? (
<div className="flex flex-col gap-0.5">
<span className="text-xs font-mono">{ENC_LABELS[t.ipsec.encAlg]} / {AUTH_LABELS[t.ipsec.authAlg]}</span>
<span className="text-xs text-muted-foreground font-mono">
{DH_LABELS[t.ipsec.dhGroup].split(" ")[0]} · {IKE_LABELS[t.ipsec.ikeVersion]}{t.ipsec.pfs && " · PFS"}
</span>
</div>
) : <span className="text-xs text-muted-foreground"></span>}
</td>
<td className="px-4 py-3 text-center font-mono text-xs">{t.mtu}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
{t.keepaliveInterval === 0 ? "откл." : `${t.keepaliveInterval}с / ${t.keepaliveRetries}`}
</td>
<td className="px-4 py-3"><TunnelStatus status={t.status} /></td>
{/* actions */}
<td className="px-3 py-3">
<div className="flex items-center gap-1 justify-end">
{/* Code preview button */}
<Button
variant="ghost" size="icon" className="size-7"
title="Предпросмотр кода RouterOS"
onClick={() => setCodePreviewTunnel(t)}
>
<CodeXmlIcon className="size-3.5" />
</Button>
{/* Actions dropdown */}
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7">
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuGroup>
<DropdownMenuLabel>{t.name}</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setCodePreviewTunnel(t)}>
<CodeXmlIcon className="size-4" /> Просмотр кода
</DropdownMenuItem>
<DropdownMenuItem>
<PencilIcon className="size-4" /> Редактировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<PowerIcon className="size-4" />
{t.enabled ? "Выключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" /> Удалить туннель
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
<GreTunnelsDataGrid
tunnels={filtered}
servers={displayServers}
pools={displayPools}
onCodePreview={setCodePreviewTunnel}
/>
</DataPageCard>
)}
{/* ── IP Pools ── */}
{pageTab === "pools" && (
<Card>
<DataPageCard>
<div className="flex items-center justify-between px-5 py-3 border-b">
<span className="text-sm text-muted-foreground">{displayPools.length} пула</span>
<Button size="sm" variant="outline" onClick={() => { setPForm(defaultPoolForm); setPoolOpen(true) }}>
<PlusIcon className="size-4" />Добавить пул
</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3">Имя пула</th>
<th className="text-left font-medium px-4 py-3">Диапазон CIDR</th>
<th className="text-right font-medium px-4 py-3">Назначено /30</th>
<th className="text-right font-medium px-4 py-3">Доступно /30</th>
<th className="text-left font-medium px-4 py-3">Использование</th>
<th className="text-left font-medium px-4 py-3">Назначение</th>
<th className="w-10 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{displayPools.map((pool) => {
const pct = pool.total > 0 ? Math.round((pool.allocated / pool.total) * 100) : 0
return (
<tr key={pool.id} className="hover:bg-muted/40 transition-colors">
<td className="px-5 py-3 font-mono text-[13px] font-medium">{pool.name}</td>
<td className="px-4 py-3 font-mono text-xs">{pool.cidr}</td>
<td className="px-4 py-3 text-right tabular-nums">{pool.allocated}</td>
<td className="px-4 py-3 text-right tabular-nums text-muted-foreground">{pool.total - pool.allocated}</td>
<td className="px-4 py-3 min-w-[140px]">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div className={`h-full rounded-full ${pct > 80 ? "bg-amber-500" : "bg-emerald-500"}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs text-muted-foreground tabular-nums w-8 text-right">{pct}%</span>
</div>
</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{pool.comment}</td>
<td className="px-3 py-3">
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7">
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem><PencilIcon className="size-4" /> Редактировать</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" /> Удалить пул</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<GrePoolsDataGrid pools={displayPools} />
<div className="border-t px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">Назначения по пулам</p>
@@ -715,7 +515,7 @@ export default function GrePage() {
})}
</div>
</div>
</Card>
</DataPageCard>
)}
{/* RouterOS reference */}
@@ -838,51 +638,51 @@ export default function GrePage() {
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<Field label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
<FormField label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
<Input className="font-mono" placeholder="gre-msk-spb" value={tForm.name} onChange={(e) => setT("name", e.target.value)} />
</Field>
<Field label="Сервер (MikroTik)" required>
</FormField>
<FormField label="Сервер (MikroTik)" required>
<select value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="" disabled>Выбрать сервер</option>
{displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
</select>
</Field>
<Field label="Комментарий">
</FormField>
<FormField label="Комментарий">
<Input placeholder="Описание туннеля" value={tForm.comment} onChange={(e) => setT("comment", e.target.value)} />
</Field>
</FormField>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Включён</span>
<Toggle checked={tForm.enabled} onChange={(v) => setT("enabled", v)} />
<FormToggle checked={tForm.enabled} onChange={(v) => setT("enabled", v)} />
</div>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Эндпоинты</SectionTitle>
<Field label="Локальный адрес" hint="Оставьте пустым или 0.0.0.0 для автоопределения">
<FormField label="Локальный адрес" hint="Оставьте пустым или 0.0.0.0 для автоопределения">
<Input className="font-mono" placeholder="0.0.0.0" value={tForm.localAddress} onChange={(e) => setT("localAddress", e.target.value)} />
</Field>
<Field label="Удалённый адрес" required hint="Внешний IP удалённого MikroTik">
</FormField>
<FormField label="Удалённый адрес" required hint="Внешний IP удалённого MikroTik">
<Input className="font-mono" placeholder="203.0.113.1" value={tForm.remoteAddress} onChange={(e) => setT("remoteAddress", e.target.value)} />
</Field>
</FormField>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Внутренний IP</SectionTitle>
<Field label="IP-пул" required hint="Из какого пула выделяется /30-блок">
<FormField label="IP-пул" required hint="Из какого пула выделяется /30-блок">
<select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="" disabled>Выбрать пул</option>
{displayPools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) свободно {p.total - p.allocated} блоков</option>)}
</select>
</Field>
</FormField>
<div className="grid grid-cols-2 gap-3">
<Field label="Локальный IP" required hint="/ip address на этом конце">
<FormField label="Локальный IP" required hint="/ip address на этом конце">
<Input className="font-mono" placeholder="10.200.0.1/30" value={tForm.localInnerIp} onChange={(e) => setT("localInnerIp", e.target.value)} />
</Field>
<Field label="Удалённый IP" required hint="/ip address на другом конце">
</FormField>
<FormField label="Удалённый IP" required hint="/ip address на другом конце">
<Input className="font-mono" placeholder="10.200.0.2/30" value={tForm.remoteInnerIp} onChange={(e) => setT("remoteInnerIp", e.target.value)} />
</Field>
</FormField>
</div>
</div>
@@ -893,12 +693,12 @@ export default function GrePage() {
<p className="text-sm font-medium">Включить IPsec</p>
<p className="text-xs text-muted-foreground">RouterOS автоматически создаст peer, policy и proposal</p>
</div>
<Toggle checked={tForm.ipsecEnabled} onChange={(v) => setT("ipsecEnabled", v)} />
<FormToggle checked={tForm.ipsecEnabled} onChange={(v) => setT("ipsecEnabled", v)} />
</div>
{tForm.ipsecEnabled && (
<div className="flex flex-col gap-4 pl-4 border-l-2 border-emerald-500/30">
<Field label="Пароль (PSK)" required hint="ipsec-secret — pre-shared key для автоматического IKE">
<FormField label="Пароль (PSK)" required hint="ipsec-secret — pre-shared key для автоматического IKE">
<div className="relative">
<Input type={tForm.ipsecShowSecret ? "text" : "password"} className="font-mono pr-9"
placeholder="Минимум 8 символов" value={tForm.ipsecSecret} onChange={(e) => setT("ipsecSecret", e.target.value)} />
@@ -907,38 +707,38 @@ export default function GrePage() {
{tForm.ipsecShowSecret ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
</button>
</div>
</Field>
<Field label="IKE-версия">
</FormField>
<FormField label="IKE-версия">
<SegmentedControl value={tForm.ipsecIkeVersion} onChange={(v) => setT("ipsecIkeVersion", v)}
options={[{ value: "ikev1", label: "IKEv1" }, { value: "ikev2", label: "IKEv2 (рек.)" }]} />
</Field>
</FormField>
<div className="grid grid-cols-2 gap-3">
<Field label="Шифрование">
<FormField label="Шифрование">
<select value={tForm.ipsecEncAlg} onChange={(e) => setT("ipsecEncAlg", e.target.value as IpsecEncAlg)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
{(Object.entries(ENC_LABELS) as [IpsecEncAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
<Field label="Хеш-алгоритм">
</FormField>
<FormField label="Хеш-алгоритм">
<select value={tForm.ipsecAuthAlg} onChange={(e) => setT("ipsecAuthAlg", e.target.value as IpsecAuthAlg)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
{(Object.entries(AUTH_LABELS) as [IpsecAuthAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
</FormField>
</div>
<Field label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
<FormField label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
<select value={tForm.ipsecDhGroup} onChange={(e) => setT("ipsecDhGroup", e.target.value as IpsecDhGroup)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
{(Object.entries(DH_LABELS) as [IpsecDhGroup, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
</FormField>
<div className="grid grid-cols-2 gap-3">
<Field label="Срок жизни SA" hint="Формат: 1d 00:00:00">
<FormField label="Срок жизни SA" hint="Формат: 1d 00:00:00">
<Input className="font-mono" value={tForm.ipsecLifetime} onChange={(e) => setT("ipsecLifetime", e.target.value)} />
</Field>
</FormField>
<div className="flex items-center justify-between pt-6">
<span className="text-sm font-medium">PFS</span>
<Toggle checked={tForm.ipsecPfs} onChange={(v) => setT("ipsecPfs", v)} />
<FormToggle checked={tForm.ipsecPfs} onChange={(v) => setT("ipsecPfs", v)} />
</div>
</div>
</div>
@@ -955,23 +755,23 @@ export default function GrePage() {
{tForm.showAdvanced && (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-3 gap-3">
<Field label="MTU" hint="По умолч. 1476">
<FormField label="MTU" hint="По умолч. 1476">
<Input type="number" className="font-mono" value={tForm.mtu} onChange={(e) => setT("mtu", Number(e.target.value))} />
</Field>
<Field label="Keepalive, с" hint="0 = откл.">
</FormField>
<FormField label="Keepalive, с" hint="0 = откл.">
<Input type="number" className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
</Field>
<Field label="Попытки">
</FormField>
<FormField label="Попытки">
<Input type="number" className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} />
</Field>
</FormField>
</div>
<Field label="DSCP">
<FormField label="DSCP">
<select value={tForm.dscp} onChange={(e) => setT("dscp", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="inherit">inherit</option>
{Array.from({ length: 64 }, (_, i) => <option key={i} value={String(i)}>{i}</option>)}
</select>
</Field>
</FormField>
{[
{ key: "clampTcpMss" as const, label: "Clamp TCP MSS", desc: "Ограничить MSS до MTU туннеля" },
{ key: "allowFastPath" as const, label: "Allow Fast Path", desc: "Аппаратное ускорение трафика" },
@@ -981,7 +781,7 @@ export default function GrePage() {
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">{desc}</p>
</div>
<Toggle checked={tForm[key] as boolean} onChange={(v) => setT(key, v)} />
<FormToggle checked={tForm[key] as boolean} onChange={(v) => setT(key, v)} />
</div>
))}
</div>
@@ -1006,18 +806,18 @@ export default function GrePage() {
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Параметры пула</SectionTitle>
<Field label="Имя пула" required hint="Например pool-gre-office или pool-gre-dc2">
<FormField label="Имя пула" required hint="Например pool-gre-office или pool-gre-dc2">
<Input className="font-mono" placeholder="pool-gre-core" value={pForm.name}
onChange={(e) => setPForm((f) => ({ ...f, name: e.target.value }))} />
</Field>
<Field label="Диапазон CIDR" required hint="Блок, из которого будут нарезаться /30 на каждый туннель">
</FormField>
<FormField label="Диапазон CIDR" required hint="Блок, из которого будут нарезаться /30 на каждый туннель">
<Input className="font-mono" placeholder="10.200.0.0/24" value={pForm.cidr}
onChange={(e) => setPForm((f) => ({ ...f, cidr: e.target.value }))} />
</Field>
<Field label="Назначение / Комментарий">
</FormField>
<FormField label="Назначение / Комментарий">
<Input placeholder="Ядровые межузловые туннели" value={pForm.comment}
onChange={(e) => setPForm((f) => ({ ...f, comment: e.target.value }))} />
</Field>
</FormField>
{pForm.cidr && /\/\d+$/.test(pForm.cidr) && (() => {
const prefix = parseInt(pForm.cidr.split("/")[1] ?? "0")
const blocks = prefix <= 30 ? Math.pow(2, 30 - prefix) : 0
+47 -55
View File
@@ -1,20 +1,25 @@
"use client"
import { useMemo } from "react"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { DataTable } from "@/components/data-table"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { IpRangesDataGrid } from "@/components/data-grids/ip-ranges-data-grid"
import { FileImportDialog } from "@/components/file-import-dialog"
import { ipRanges as mockIpRanges } from "@/lib/data"
import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
export default function IpRangesPage() {
const { mode } = useDataSource()
const { enabled, snapshot, loading, error } = useEvoBGP()
const [importOpen, setImportOpen] = useState(false)
const [search, setSearch] = useState("")
/** При включённом EvoBGP в live локальные моки не показываем — только каталог API (или пусто при загрузке/ошибке). */
const useEvoCatalog = mode === "live" && enabled
const rows = useMemo(() => {
@@ -23,13 +28,27 @@ export default function IpRangesPage() {
return snapshot?.ipRanges ?? []
}, [useEvoCatalog, loading, snapshot])
const filtered = useMemo(() => {
if (!search) return rows
const q = search.toLowerCase()
return rows.filter(
(r) =>
r.cidr.toLowerCase().includes(q) ||
r.asn.toLowerCase().includes(q) ||
r.country.toLowerCase().includes(q) ||
r.filter.toLowerCase().includes(q),
)
}, [rows, search])
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
actions={
<>
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />Импорт
</Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
<Button size="sm"><PlusIcon className="size-4" />Добавить диапазон</Button>
</>
@@ -56,58 +75,31 @@ export default function IpRangesPage() {
</p>
)}
</div>
<DataTable
data={rows}
searchPlaceholder="Поиск по CIDR, ASN…"
searchKeys={["cidr", "asn", "country", "filter"]}
columns={[
{
key: "cidr",
label: "CIDR",
render: (d) => <span className="font-mono font-medium">{d.cidr}</span>,
},
{
key: "asn",
label: "ASN",
render: (d) => <span className="font-mono text-xs text-muted-foreground">{d.asn}</span>,
},
{
key: "country",
label: "Страна",
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.country}</span>,
},
{
key: "purpose",
label: "Назначение",
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.purpose}</span>,
},
{
key: "filter",
label: "Фильтр",
render: (d) => (
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
</span>
),
},
{
key: "updated",
label: "Обновлён",
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
},
{
key: "enabled",
label: "Статус",
render: (d) => (
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
{d.enabled ? "Активен" : "Отключён"}
</span>
),
},
]}
/>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по CIDR, ASN…"
countLabel={`${filtered.length} диапазонов`}
/>
<IpRangesDataGrid
ipRanges={filtered}
isLoading={useEvoCatalog && loading && !snapshot}
pagination={useEvoCatalog}
/>
</DataPageCard>
</div>
</div>
<FileImportDialog
open={importOpen}
onOpenChange={setImportOpen}
title="Импорт IP-диапазонов"
description="Загрузите CSV или JSON со списком CIDR-блоков"
accept=".csv,.json,text/csv,application/json"
onImport={async (files) => {
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
}}
/>
</div>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { Skeleton } from "@/components/ui/skeleton"
export default function MainLoading() {
return (
<div className="flex flex-col gap-5 p-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
<Skeleton className="h-10 w-full max-w-md rounded-lg" />
<Skeleton className="h-96 w-full rounded-xl" />
</div>
)
}
+18 -151
View File
@@ -2,6 +2,10 @@
import { useMemo, useState, useEffect } from "react"
import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card"
import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-data-grid"
import { OspfRoutesDataGrid, routeTypeClass } from "@/components/data-grids/ospf-routes-data-grid"
import { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
@@ -375,13 +379,6 @@ function stateClass(state: OspfNeighbor["state"] | BfdSession["state"]) {
return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25"
}
function routeTypeClass(type: OspfRoute["type"]) {
if (type === "O") return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25"
if (type === "O IA") return "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/25"
if (type === "O E1") return "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/25"
return "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/25"
}
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
return (
<span className={cn(
@@ -978,60 +975,14 @@ function NeighborsTab({
</div>
)}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/40">
{["Роутер", "Интерфейс", "Сосед (Router ID)", "Область", "Состояние", "Cost", "Uptime", "Prio"].map(h => (
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{neighbors.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-sm text-muted-foreground">
Нет OSPF-соседей
</td>
</tr>
) : neighbors.map(n => {
const isHighlighted = selectedId === n.localRouter || selectedId === n.remoteRouter
return (
<tr key={n.id}
onMouseEnter={() => setHighlightId(n.localRouter)}
onMouseLeave={() => setHighlightId(null)}
onClick={() => setSelectedId(prev => prev === n.localRouter ? null : n.localRouter)}
className={cn(
"transition-colors cursor-pointer",
isHighlighted ? "bg-primary/5 hover:bg-primary/8" : "hover:bg-muted/30",
)}>
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{n.localLabel}</td>
<td className="px-3 py-2.5 font-mono text-muted-foreground whitespace-nowrap">{n.localIface}</td>
<td className="px-3 py-2.5">
<div className="flex flex-col">
<span className="font-mono">{n.remoteLabel !== n.remoteRouterId ? n.remoteLabel : n.remoteRouterId}</span>
{n.remoteLabel !== n.remoteRouterId && (
<span className="text-[10px] font-mono text-muted-foreground">{n.remoteRouterId}</span>
)}
</div>
</td>
<td className="px-3 py-2.5 font-mono text-muted-foreground">{n.area}</td>
<td className="px-3 py-2.5">
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium", stateClass(n.state))}>
{n.state}
</span>
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-center">{n.cost}</td>
<td className="px-3 py-2.5 text-muted-foreground whitespace-nowrap tabular-nums">{n.uptime}</td>
<td className="px-3 py-2.5 text-center font-mono">{n.priority}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
<DataPageCard>
<OspfNeighborsDataGrid
neighbors={neighbors}
selectedRouterId={selectedId}
onSelect={setSelectedId}
onHighlight={setHighlightId}
/>
</DataPageCard>
</div>
)
}
@@ -1052,36 +1003,9 @@ function RoutesTab({ routes }: { routes: OspfRoute[] }) {
</span>
))}
</div>
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/40">
{["Назначение", "Тип", "Cost", "Следующий хоп", "Интерфейс", "Роутер", "Область"].map(h => (
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{routes.map(r => (
<tr key={r.id} className="hover:bg-muted/30 transition-colors">
<td className="px-3 py-2.5 font-mono">{r.destination}</td>
<td className="px-3 py-2.5">
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium", routeTypeClass(r.type))}>
{r.type}
</span>
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-center">{r.cost}</td>
<td className="px-3 py-2.5 font-mono text-muted-foreground">{r.nextHop}</td>
<td className="px-3 py-2.5 font-mono text-muted-foreground">{r.via}</td>
<td className="px-3 py-2.5 font-mono">{r.serverLabel}</td>
<td className="px-3 py-2.5 font-mono text-muted-foreground">{r.area}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<DataPageCard>
<OspfRoutesDataGrid routes={routes} />
</DataPageCard>
<div className="flex items-center gap-5 flex-wrap px-1">
<span className="text-xs text-muted-foreground">Типы:</span>
{([
@@ -1139,66 +1063,9 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
)}
{sessions.length > 0 && (
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/40">
{[
"Роутер", "Интерфейс", "Локальный", "Удалённый",
"Состояние", "Uptime", "Tx / Rx", "Hold", "Mult",
"Пакеты Rx", "Пакеты Tx", "Переходы",
].map(h => (
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{sessions.map(b => (
<tr key={b.id} className="hover:bg-muted/30 transition-colors">
<td className="px-3 py-2.5 font-mono whitespace-nowrap">
<div className="flex flex-col gap-0.5">
<span>{b.serverLabel}</span>
{b.multihop && (
<Chip color="bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20">multihop</Chip>
)}
</div>
</td>
<td className="px-3 py-2.5 font-mono text-muted-foreground whitespace-nowrap">{b.iface || "—"}</td>
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{b.localAddr}</td>
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{b.remoteAddr}</td>
<td className="px-3 py-2.5">
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium", stateClass(b.state))}>
{b.state}
</span>
</td>
<td className="px-3 py-2.5 text-muted-foreground whitespace-nowrap tabular-nums">
{b.uptime ?? "—"}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-muted-foreground whitespace-nowrap">
{fmtMs(b.interval)} / {fmtMs(b.rxInterval)}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-muted-foreground whitespace-nowrap">
{fmtMs(b.holdTime)}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-center">{b.multiplier}</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-right text-muted-foreground">
{b.packetsRx.toLocaleString()}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-right text-muted-foreground">
{b.packetsTx.toLocaleString()}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-center">
<span className={cn(b.stateChanges > 3 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground")}>
{b.stateChanges}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<DataPageCard>
<OspfBfdDataGrid sessions={sessions} />
</DataPageCard>
)}
{sessions.length > 0 && (
+30 -135
View File
@@ -2,7 +2,18 @@
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit"
import { Card, CardContent } from "@/components/ui/card"
import { DataPageCard } from "@/components/data-page-card"
import {
ProbesScheduleDataGrid,
type SchedRule,
type SchedType,
} from "@/components/data-grids/probes-schedule-data-grid"
import {
ProbesSpeedProbesDataGrid,
type SpeedProbeApiRow,
} from "@/components/data-grids/probes-speed-probes-data-grid"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
@@ -40,13 +51,8 @@ interface DiagTest {
source?: "demo" | "live"
}
type SchedType = "ping" | "bandwidth" | "both"
// SchedRule imported from probes-schedule-data-grid
interface SchedRule {
id: string; srcId: string; tunnelId: string; type: SchedType
intervalMin: number; enabled: boolean
lastRun: string | null; nextRunMin: number | null
}
// ─── tool metadata ────────────────────────────────────────────────────────────
@@ -97,22 +103,7 @@ interface BackendServerRow {
latency?: number | null
}
interface SpeedProbeApiRow {
id: string
srcServerId: string
dstServerId: string
srcInterface: string
dstInterface: string
protocol: string
direction: string
durationSec: string
enabled: boolean
lastRunAt: string | null
lastTxAvgMbps: number | null
lastRxAvgMbps: number | null
lastStatus: string | null
lastError: string | null
}
// SpeedProbeApiRow imported from probes-speed-probes-data-grid
// ─── helpers ──────────────────────────────────────────────────────────────────
@@ -353,17 +344,6 @@ function NativeSelect({ value, onChange, children, className }: {
)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button onClick={() => onChange(!checked)}
className={cn("relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
checked ? "bg-primary" : "bg-muted-foreground/30")}>
<span className={cn("inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform",
checked ? "translate-x-4" : "translate-x-0.5")} />
</button>
)
}
function OptionLabel({ children }: { children: React.ReactNode }) {
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
}
@@ -476,53 +456,9 @@ function ScheduleSpeedProbesLive({
return (
<div className="flex flex-col gap-3">
<Card className="overflow-hidden">
{rows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 gap-2 text-muted-foreground">
<ClockIcon className="size-7 opacity-20" />
<p className="text-sm">Нет записей speed-test в мониторинге</p>
<p className="text-xs text-muted-foreground/70 max-w-md text-center">
Настраиваются через API <code className="text-[11px]">PUT /api/uptime/speed-probes</code> или связанный UI.
</p>
</div>
) : (
<>
<div className="grid grid-cols-[1fr_1fr_80px_90px_80px_1fr] gap-2 items-center px-4 py-2 bg-muted/30 border-b text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
<span>Источник</span>
<span>Назначение</span>
<span>Протокол</span>
<span>Сек</span>
<span>Вкл</span>
<span>Последний запуск</span>
</div>
<div className="divide-y divide-border/60">
{rows.map(r => (
<div key={r.id} className={cn(
"grid grid-cols-[1fr_1fr_80px_90px_80px_1fr] gap-2 items-center px-4 py-2.5 text-xs",
!r.enabled && "opacity-50",
)}>
<span className="truncate font-mono">{name(r.srcServerId)}{r.srcInterface ? ` · ${r.srcInterface}` : ""}</span>
<span className="truncate font-mono">{name(r.dstServerId)}{r.dstInterface ? ` · ${r.dstInterface}` : ""}</span>
<span>{r.protocol.toUpperCase()}</span>
<span className="font-mono">{r.durationSec}s</span>
<span>{r.enabled ? "да" : "нет"}</span>
<span className="text-muted-foreground truncate">
{r.lastRunAt ?? "—"}
{r.lastStatus === "done" && r.lastTxAvgMbps != null && (
<span className="text-emerald-600 dark:text-emerald-400 ml-1">
TX{r.lastTxAvgMbps.toFixed(1)} RX{(r.lastRxAvgMbps ?? 0).toFixed(1)} Mb/s
</span>
)}
{r.lastStatus === "error" && r.lastError && (
<span className="text-destructive ml-1 truncate">{r.lastError}</span>
)}
</span>
</div>
))}
</div>
</>
)}
</Card>
<DataPageCard>
<ProbesSpeedProbesDataGrid rows={rows} serverName={name} />
</DataPageCard>
<p className="text-[11px] text-muted-foreground">
Данные из коллектора uptime (та же БД, что и дашборд). Редактирование через настройки мониторинга / API.
</p>
@@ -563,63 +499,22 @@ function ScheduleTab({
}
}, [addSrc, addTun, tunnelsForServer])
const typeLabel: Record<SchedType, string> = { ping: "Ping", bandwidth: "BW-тест", both: "Ping + BW" }
const tunnelName = (srcId: string, tunnelId: string) =>
tunnelsForServer(srcId).find((t) => t.id === tunnelId)?.name
return (
<div className="flex flex-col gap-3">
<Card className="overflow-hidden">
{rules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 gap-2 text-muted-foreground">
<ClockIcon className="size-7 opacity-20" />
<p className="text-sm">Нет правил расписания</p>
</div>
) : (
<>
<div className="grid grid-cols-[40px_1fr_140px_80px_100px_1fr_auto] gap-2 items-center px-4 py-2 bg-muted/30 border-b text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
<span />
<span>Туннель</span>
<span>Сервер</span>
<span>Тип</span>
<span>Интервал</span>
<span>Последний / следующий</span>
<span />
</div>
<div className="divide-y divide-border/60">
{rules.map(rule => {
const src = serverOptions.find(s => s.id === rule.srcId)
const tun = tunnelsForServer(rule.srcId).find(t => t.id === rule.tunnelId)
return (
<div key={rule.id} className={cn(
"grid grid-cols-[40px_1fr_140px_80px_100px_1fr_auto] gap-2 items-center px-4 py-2.5 hover:bg-muted/20 transition-colors",
!rule.enabled && "opacity-50",
)}>
<Toggle checked={rule.enabled}
onChange={v => setRules(p => p.map(r => r.id === rule.id ? { ...r, enabled: v } : r))} />
<code className="font-mono text-xs truncate">{tun?.name ?? rule.tunnelId}</code>
<span className="text-xs text-muted-foreground truncate">{src?.name ?? rule.srcId}</span>
<span className={cn("text-[10px] px-1.5 py-0.5 rounded border font-medium w-fit",
rule.type === "ping" ? "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20"
: rule.type === "bandwidth" ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
)}>{typeLabel[rule.type]}</span>
<span className="text-xs text-muted-foreground">каждые {rule.intervalMin} мин</span>
<div className="text-xs text-muted-foreground flex items-center gap-2 min-w-0">
{rule.lastRun && <span className="truncate">{rule.lastRun}</span>}
{rule.nextRunMin != null && rule.enabled && (
<span className="text-sky-600 dark:text-sky-400 shrink-0">· через {rule.nextRunMin} мин</span>
)}
</div>
<button onClick={() => setRules(p => p.filter(r => r.id !== rule.id))}
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-red-500 hover:bg-red-500/10 transition-colors">
<Trash2Icon className="size-3.5" />
</button>
</div>
)
})}
</div>
</>
)}
</Card>
<DataPageCard>
<ProbesScheduleDataGrid
rules={rules}
serverOptions={serverOptions}
tunnelName={tunnelName}
onToggleEnabled={(id, enabled) =>
setRules((p) => p.map((r) => (r.id === id ? { ...r, enabled } : r)))
}
onDelete={(id) => setRules((p) => p.filter((r) => r.id !== id))}
/>
</DataPageCard>
{showAdd ? (
<Card className="overflow-hidden">
<div className="px-4 py-3 border-b flex items-center gap-2 text-sm font-medium">
@@ -1115,7 +1010,7 @@ export default function ProbesPage() {
Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.
</p>
</div>
<Toggle checked={traceUseDns} onChange={setTraceUseDns} />
<FormToggle checked={traceUseDns} onChange={setTraceUseDns} />
</div>
</>
)}
+30 -198
View File
@@ -2,6 +2,12 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import {
RecursiveRoutesDataGrid,
type RecursiveRouteGroup,
inferCountry,
} from "@/components/data-grids/recursive-routes-data-grid"
import { FormField, SectionTitle } from "@/components/form-kit"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -11,7 +17,7 @@ import { Flag } from "@/components/flag"
import { useDataSource } from "@/lib/data-source"
import { cn } from "@/lib/utils"
import { servers as mockServers, type Server } from "@/lib/data"
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, ChevronDownIcon, ChevronRightIcon, AlertCircleIcon, CheckIcon } from "lucide-react"
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react"
import { requestJson } from "@/shared/api/http-client"
interface BackendServer {
@@ -34,25 +40,6 @@ interface GatewayOption {
status: "up" | "down"
}
const INFER_COUNTRIES = [
{ code: "RU", keys: ["MSK", "SPB", "RTK", "MTS", "VPSVILLE", "IHOR"] },
{ code: "SE", keys: ["SWE", "STO"] },
{ code: "FI", keys: ["HEL", "FIN"] },
{ code: "DE", keys: ["FRA", "GER", "DE"] },
{ code: "NL", keys: ["AMS", "NLD", "NL"] },
{ code: "SG", keys: ["SGP", "SIN", "SG"] },
{ code: "TR", keys: ["TUR", "TR"] },
{ code: "US", keys: ["USA", "US", "NYC", "LAX"] },
]
function inferCountry(name: string): string | null {
const upper = name.toUpperCase()
for (const c of INFER_COUNTRIES) {
if (c.keys.some(k => upper.includes(k))) return c.code
}
return null
}
const COUNTRY_OPTIONS = [
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
@@ -82,13 +69,7 @@ interface RecursiveRouteRow {
country: string
}
interface RouteGroup {
key: string
dstAddress: string
routingTable: string
comment: string
endpoints: RecursiveRouteRow[]
}
interface RouteGroup extends RecursiveRouteGroup {}
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
@@ -148,131 +129,6 @@ const emptyForm = (): RouteForm => ({
endpoints: [newEndpoint()],
})
function Field({ label, hint, required, children }: {
label: string
hint?: string
required?: boolean
children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 py-0.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
function RouteGroupRows({
group, expanded, onToggle, onEdit, onDelete,
}: {
group: RouteGroup
expanded: boolean
onToggle: () => void
onEdit: () => void
onDelete: () => void
}) {
const [confirmDel, setConfirmDel] = useState(false)
const bestDistance = Math.min(...group.endpoints.map(ep => ep.distance))
const sorted = [...group.endpoints].sort((a, b) => a.distance - b.distance)
return (
<>
<tr
className={cn(
"hover:bg-muted/40 transition-colors cursor-pointer group",
expanded && "bg-muted/30",
)}
onClick={onToggle}
>
<td className="px-5 py-3">
<div className="flex items-start gap-2">
{expanded
? <ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
: <ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />}
<div className="min-w-0">
<p className="font-medium truncate">{group.dstAddress}</p>
<p className="text-xs font-mono text-muted-foreground">{group.comment || "—"}</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<div className="flex flex-col gap-0.5">
{sorted.map((ep, idx) => {
const code = ep.country || inferCountry(ep.gateway)
return (
<div key={ep.id} className="flex items-center gap-1.5 text-[11px] font-mono">
<span className={cn(
"size-1.5 rounded-full shrink-0",
idx === 0 ? "bg-emerald-500" : "bg-sky-500",
)} />
{code ? <Flag code={code} size={14} className="shrink-0" /> : <span className="text-[10px] text-muted-foreground w-3.5 text-center shrink-0">?</span>}
<span className="font-semibold text-sky-600 dark:text-sky-400 truncate min-w-0">{ep.gateway}</span>
</div>
)
})}
</div>
</td>
<td className="px-4 py-3 text-xs tabular-nums">{group.endpoints.length}</td>
<td className="px-4 py-3 font-mono text-xs">d{bestDistance}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{group.routingTable || "main"}</td>
<td className="px-3 py-3" onClick={e => e.stopPropagation()}>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button size="sm" variant="ghost" className="size-7 p-0 text-muted-foreground hover:text-foreground" onClick={onEdit}><PencilIcon className="size-3.5" /></Button>
<Button size="sm" variant="ghost" className={cn("size-7 p-0 transition-colors", confirmDel ? "text-destructive bg-destructive/10 hover:bg-destructive/20" : "text-muted-foreground hover:text-destructive")} onClick={() => { if (!confirmDel) setConfirmDel(true); else onDelete() }} onBlur={() => setConfirmDel(false)}>
{confirmDel ? <AlertCircleIcon className="size-3.5" /> : <TrashIcon className="size-3.5" />}
</Button>
</div>
</td>
</tr>
{expanded && (
<tr className="bg-muted/20">
<td colSpan={6} className="px-8 py-5 border-b border-border/50">
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
<span className="text-muted-foreground">Route: <span className="font-mono text-foreground">{group.dstAddress}</span></span>
<span className="text-muted-foreground">Table: <span className="font-mono text-foreground">{group.routingTable || "main"}</span></span>
{group.comment && <span className="text-muted-foreground italic">{group.comment}</span>}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5">
{sorted.map((ep, idx) => (
<div key={ep.id} className="rounded-lg border border-border bg-background px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
{(ep.country || inferCountry(ep.gateway)) && (
<Flag code={ep.country || inferCountry(ep.gateway) || ""} size={16} />
)}
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide">Endpoint {idx + 1}</span>
</div>
<span className="text-[11px] font-mono">distance: {ep.distance}</span>
</div>
<p className="mt-1.5 font-mono text-sm break-all leading-tight">{ep.gateway}</p>
<div className="mt-1.5 text-[11px] text-muted-foreground flex items-center gap-3">
<span>scope: {ep.scope ?? "—"}</span>
<span>t.scope: {ep.targetScope ?? "—"}</span>
<span>check: {ep.checkGateway || "—"}</span>
</div>
</div>
))}
</div>
</div>
</td>
</tr>
)}
</>
)
}
function EndpointCountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [query, setQuery] = useState("")
const q = query.trim().toUpperCase()
@@ -370,9 +226,9 @@ function RouteSheet({
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<Field label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24">
<FormField label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24">
<Input className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} />
</Field>
</FormField>
</div>
<div className="flex flex-col gap-4">
@@ -396,26 +252,26 @@ function RouteSheet({
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
<Field label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
<FormField label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
<Input className="font-mono h-9" placeholder="1.2.3.4%GW-NAME" value={ep.gateway} onChange={(e) => setEp(ep.id, "gateway", e.target.value)} />
</Field>
</FormField>
<div className="grid grid-cols-2 gap-2">
<Field label="Distance (приоритет)">
<FormField label="Distance (приоритет)">
<Input type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value) || 1)} />
</Field>
<Field label="Check Gateway">
</FormField>
<FormField label="Check Gateway">
<Input className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
</Field>
</FormField>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="Scope">
<FormField label="Scope">
<Input type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
</Field>
<Field label="T.Scope">
</FormField>
<FormField label="T.Scope">
<Input type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
</Field>
</FormField>
</div>
<div className="flex flex-col gap-1.5 max-h-[180px] overflow-y-auto overflow-x-hidden pr-1">
@@ -467,10 +323,10 @@ function RouteSheet({
<div className="flex flex-col gap-4">
<SectionTitle>Параметры</SectionTitle>
<Field label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></Field>
<Field label="Комментарий">
<FormField label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></FormField>
<FormField label="Комментарий">
<Input className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
</Field>
</FormField>
</div>
{error && <div className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon className="size-4 shrink-0" />{error}</div>}
</div>
@@ -819,37 +675,13 @@ export default function RecursiveRoutesPage() {
</div>
)}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3">Route / Comment</th>
<th className="text-left font-medium px-4 py-3">Gateways</th>
<th className="text-left font-medium px-4 py-3">EP</th>
<th className="text-left font-medium px-4 py-3">Priority</th>
<th className="text-left font-medium px-4 py-3">Table</th>
<th className="w-10 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{groupedRoutes.map((g) => (
<RouteGroupRows
key={g.key}
group={g}
expanded={expandedGroupKey === g.key}
onToggle={() => setExpandedGroupKey(prev => prev === g.key ? null : g.key)}
onEdit={() => openEdit(g)}
onDelete={() => setRows(prev => prev.filter(r => groupKeyOf(r) !== g.key))}
/>
))}
</tbody>
</table>
{groupedRoutes.length === 0 && (
<div className="p-8 text-center text-sm text-muted-foreground">
Нет маршрутов в БД для этого сервера. Нажми &quot;Router =&gt; DB&quot; для загрузки.
</div>
)}
</div>
<RecursiveRoutesDataGrid
groups={groupedRoutes.map((g) => ({ ...g, id: g.key }))}
expandedKey={expandedGroupKey}
onExpandedChange={setExpandedGroupKey}
onEdit={openEdit}
onDelete={(g) => setRows((prev) => prev.filter((r) => groupKeyOf(r) !== g.key))}
/>
<button onClick={openCreate}
className="w-full flex items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
<PlusIcon className="size-3.5" />
+18 -382
View File
@@ -3,6 +3,12 @@
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
import Link from "next/link"
import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card"
import { RouteOptimizerWanMatrixDataGrid } from "@/components/data-grids/route-optimizer-wan-matrix-data-grid"
import { RouteOptimizerFullRoutesDataGrid } from "@/components/data-grids/route-optimizer-full-routes-data-grid"
import { RouteOptimizerCommRecsDataGrid } from "@/components/data-grids/route-optimizer-comm-recs-data-grid"
import { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid"
import { FormToggle } from "@/components/form-kit"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -245,17 +251,6 @@ function LossChip({ loss }: { loss: number }) {
)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button type="button" onClick={() => onChange(!checked)}
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
checked ? "bg-primary" : "bg-input")}>
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0")} />
</button>
)
}
function NInput({ value, onChange, min, max }: { value: number; onChange: (v: number) => void; min?: number; max?: number }) {
return (
<Input type="number" value={value} min={min} max={max}
@@ -276,327 +271,6 @@ function SettingRow({ label, unit, children }: { label: string; unit?: string; c
)
}
// ─── WAN Matrix table ─────────────────────────────────────────────────────────
// Rows = WANs, Columns = JHs, cells show ping / bw / score
function WanMatrix({ home, legs, jumpHosts, pw: _pw }: {
home: HomeRouter
legs: WanJhLeg[]
jumpHosts: JumpHost[]
pw: number
}) {
// find best leg overall
const bestScore = legs.length ? Math.max(...legs.map(l => l.score)) : 0
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-[11px] text-muted-foreground bg-muted/20">
<th className="text-left font-medium px-4 py-2 w-[160px]">WAN-аплинк</th>
<th className="text-left font-medium px-3 py-2">ISP / IP</th>
<th className="text-right font-medium px-3 py-2">Макс. полоса</th>
{jumpHosts.map(jh => (
<th key={jh.id} className="text-center font-medium px-3 py-2 min-w-[130px]">
<div>{jh.label}</div>
<div className="font-mono font-normal text-[10px] opacity-60 flex items-center justify-center gap-1">
<Flag code={jh.country} />
{jh.site} · {jh.ip}
</div>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{home.wans.map(wan => (
<tr key={wan.id} className="hover:bg-muted/30 transition-colors">
{/* WAN name */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<WifiIcon className="size-3.5 text-muted-foreground shrink-0" />
<div>
<p className="font-mono text-xs font-semibold">{wan.name}</p>
<p className="text-[10px] text-muted-foreground">{wan.iface}</p>
</div>
</div>
</td>
{/* ISP */}
<td className="px-3 py-3">
<p className="text-xs font-medium">{wan.isp}</p>
<p className="font-mono text-[10px] text-muted-foreground">{wan.ip}</p>
</td>
{/* Max bandwidth */}
<td className="px-3 py-3 text-right">
<p className="font-mono text-xs">{wan.maxDl}</p>
<p className="font-mono text-[10px] text-muted-foreground">{wan.maxUl} Мбит</p>
</td>
{/* Per-JH cells */}
{jumpHosts.map(jh => {
const leg = legs.find(l => l.wanId === wan.id && l.jhId === jh.id)
if (!leg) return <td key={jh.id} className="px-3 py-3 text-center text-muted-foreground text-xs"></td>
const isBest = leg.score === bestScore
return (
<td key={jh.id} className={cn(
"px-3 py-3 text-center",
isBest && "bg-emerald-500/5",
)}>
<div className={cn(
"flex flex-col items-center gap-0.5 rounded-md px-2 py-1.5 transition-colors",
isBest
? "border border-emerald-500/20 bg-emerald-500/8"
: "border border-transparent",
)}>
{isBest && (
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 mb-0.5">
ЛУЧШИЙ
</span>
)}
<span className={cn("font-mono text-xs font-semibold",
leg.pingMs < 10 ? "text-emerald-600 dark:text-emerald-400"
: leg.pingMs < 25 ? "text-foreground"
: "text-amber-600 dark:text-amber-400"
)}>
{leg.pingMs} мс
</span>
<span className="text-[10px] text-muted-foreground font-mono">
{leg.dlMbps} {leg.ulMbps}
</span>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] font-mono text-foreground/70">
score {leg.score}
</span>
{leg.loss > 0 && <LossChip loss={leg.loss} />}
</div>
</div>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)
}
// ─── Full routes table ────────────────────────────────────────────────────────
function FullRoutesTable({ routes, bestId }: { routes: FullRoute[]; bestId?: string }) {
const [expanded, setExpanded] = useState(false)
const visible = expanded ? routes : routes.slice(0, 5)
return (
<div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-[11px] text-muted-foreground bg-muted/20">
<th className="text-left font-medium px-4 py-2"># Маршрут</th>
<th className="text-left font-medium px-3 py-2">WAN JH</th>
<th className="text-left font-medium px-3 py-2">JH Exit</th>
<th className="text-center font-medium px-3 py-2">Ping (итого)</th>
<th className="text-center font-medium px-3 py-2">BW (мин)</th>
<th className="text-center font-medium px-3 py-2">Score</th>
<th className="text-center font-medium px-3 py-2">P(opt)</th>
<th className="text-center font-medium px-3 py-2">Conf.</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{visible.map((r, i) => {
const isBest = r.id === bestId || i === 0
const totalPing = r.hw.pingMs + r.je.pingMs
const minDl = Math.min(r.hw.dlMbps, r.je.dlMbps)
const minUl = Math.min(r.hw.ulMbps, r.je.ulMbps)
return (
<tr key={r.id} className={cn(
"hover:bg-muted/30 transition-colors",
isBest && "bg-emerald-500/5",
)}>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-muted-foreground w-4">{i + 1}</span>
{isBest && (
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-1.5 py-0.5 rounded">
Лучший
</span>
)}
</div>
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-1.5 text-xs">
<span className="font-mono font-semibold text-sky-600 dark:text-sky-400">{r.wan.name}</span>
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
<div>
<div className="font-medium">{r.jh.label}</div>
<div className="font-mono text-[10px] text-muted-foreground">{r.hw.pingMs} мс · {r.hw.dlMbps} {r.hw.ulMbps}</div>
</div>
</div>
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-1.5 text-xs">
<div>
<div className="flex items-center gap-1 font-medium">
<Flag code={r.exit.country} />
{r.exit.label}
<span className="text-[10px] text-muted-foreground">({r.exit.site})</span>
</div>
<div className="font-mono text-[10px] text-muted-foreground">{r.je.pingMs} мс · {r.je.dlMbps} {r.je.ulMbps}</div>
</div>
</div>
</td>
<td className={cn("px-3 py-2.5 text-center font-mono text-xs",
totalPing < 40 ? "text-emerald-600 dark:text-emerald-400"
: totalPing < 80 ? "text-amber-600 dark:text-amber-400"
: "text-red-500"
)}>
{totalPing} мс
</td>
<td className="px-3 py-2.5 text-center font-mono text-xs text-muted-foreground">
<div>{minDl}</div>
<div>{minUl}</div>
</td>
<td className="px-3 py-2.5 text-center font-mono text-xs font-semibold">{r.score}</td>
<td className="px-3 py-2.5 text-center"><ProbChip prob={r.probabilityOptimal} best={isBest} /></td>
<td className="px-3 py-2.5 text-center"><ConfChip conf={r.confidence} /></td>
</tr>
)
})}
</tbody>
</table>
</div>
{routes.length > 5 && (
<button onClick={() => setExpanded(v => !v)}
className="w-full py-2 text-xs text-muted-foreground hover:text-foreground transition-colors border-t flex items-center justify-center gap-1">
{expanded
? <><ChevronUpIcon className="size-3" />Свернуть</>
: <><ChevronDownIcon className="size-3" />Показать все {routes.length} комбинаций</>}
</button>
)}
</div>
)
}
// ─── Community recs table ─────────────────────────────────────────────────────
function CommRecsTable({ recs, homeId, pinned, applied, applying, onPin, onApply, threshold: _threshold }: {
recs: CommRec[]
homeId: string
pinned: Set<string>
applied: Set<string>
applying: Set<string>
onPin: (k: string) => void
onApply: (comm: string, homeId: string) => void
threshold: number
}) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-[11px] text-muted-foreground bg-muted/20">
<th className="text-left font-medium px-4 py-2">Community</th>
<th className="text-left font-medium px-3 py-2">Текущий (WAN JH Exit)</th>
<th className="text-left font-medium px-3 py-2">Рекомендуемый</th>
<th className="text-center font-medium px-3 py-2">P(тек / рек)</th>
<th className="text-right font-medium px-3 py-2">Действие</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{recs.map((r, idx) => {
const pinKey = `${homeId}::${r.community}`
const isPinned = pinned.has(pinKey)
const isApplied = applied.has(pinKey)
const isApplying = applying.has(pinKey)
const canApply = r.shouldSwitch && !isPinned && !isApplied
return (
<tr key={`${homeId}::${r.community}::${idx}`} className={cn(
"hover:bg-muted/30 transition-colors",
r.shouldSwitch && !isPinned && !isApplied && "bg-amber-500/5",
isApplied && "bg-emerald-500/5",
)}>
{/* community */}
<td className="px-4 py-2.5">
<div className="font-mono text-xs font-medium">{r.community}</div>
<div className="text-[11px] text-muted-foreground">{r.communityName}</div>
</td>
{/* current route */}
<td className="px-3 py-2.5">
{r.current ? (
<div className="text-xs flex items-center gap-1 flex-wrap">
<span className="font-mono font-medium text-sky-600 dark:text-sky-400">{r.current.wan}</span>
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
<span>{r.current.jh}</span>
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">{r.current.exit}</span>
<span className="font-mono text-[10px] text-muted-foreground">({r.current.gateway})</span>
</div>
) : <span className="text-muted-foreground text-xs"></span>}
</td>
{/* recommended */}
<td className="px-3 py-2.5">
{r.recommended ? (
<div className={cn("text-xs flex items-center gap-1 flex-wrap",
r.shouldSwitch && !isPinned && "text-amber-600 dark:text-amber-400")}>
<span className="font-mono font-medium">{r.recommended.wan}</span>
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
<span>{r.recommended.jh}</span>
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
<span>{r.recommended.exit}</span>
{r.shouldSwitch && !isPinned && (
<span className="ml-1 text-[10px] font-bold bg-amber-500/10 border border-amber-500/20 px-1.5 py-0.5 rounded">
+{(r.recommended.prob ?? 0) - (r.current?.prob ?? 0)}%
</span>
)}
</div>
) : <span className="text-muted-foreground text-xs"></span>}
</td>
{/* probability */}
<td className="px-3 py-2.5 text-center">
<div className="flex items-center justify-center gap-1">
<ProbChip prob={r.current?.prob ?? 0} />
<span className="text-muted-foreground text-[10px]">/</span>
<ProbChip prob={r.recommended?.prob ?? 0} best={r.shouldSwitch && !isPinned} />
</div>
</td>
{/* actions */}
<td className="px-3 py-2.5">
<div className="flex items-center justify-end gap-1.5">
{isPinned && <PinIcon className="size-3 text-sky-500 fill-sky-500" />}
<Button variant="outline" size="sm"
className={cn("h-7 text-xs", isPinned && "text-sky-600 dark:text-sky-400 border-sky-500/30")}
onClick={() => onPin(pinKey)}>
<PinIcon className={cn("size-3", isPinned && "fill-current")} />
{isPinned ? "Открепить" : "Закрепить"}
</Button>
{canApply && (
<Button size="sm" className="h-7 text-xs" disabled={isApplying}
onClick={() => onApply(r.community, homeId)}>
{isApplying
? <RefreshCwIcon className="size-3 animate-spin" />
: <PlayIcon className="size-3" />}
Применить
</Button>
)}
{isApplied && (
<span className="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
<CheckCircleIcon className="size-3" />Применено
</span>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
// ─── Home Router card ─────────────────────────────────────────────────────────
type HomeTab = "wan-matrix" | "full-routes" | "bgp-community"
@@ -679,18 +353,17 @@ function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying,
{/* Tab content */}
{tab === "wan-matrix" && (
<WanMatrix home={home} legs={wanJhLegs} jumpHosts={jumpHosts} pw={settings.pingWeight} />
<RouteOptimizerWanMatrixDataGrid home={home} legs={wanJhLegs} jumpHosts={jumpHosts} />
)}
{tab === "full-routes" && (
<FullRoutesTable routes={fullRoutes} bestId={bestRoute?.id} />
<RouteOptimizerFullRoutesDataGrid routes={fullRoutes} bestId={bestRoute?.id} />
)}
{tab === "bgp-community" && (
<CommRecsTable
<RouteOptimizerCommRecsDataGrid
recs={commRecs}
homeId={home.id}
pinned={pinned} applied={applied} applying={applying}
onPin={onPin} onApply={onApply}
threshold={settings.switchThreshold}
/>
)}
</Card>
@@ -1167,7 +840,7 @@ export default function RouteOptimizerPage() {
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Автоприменение</p>
<div className="flex items-center justify-between">
<span className="text-sm">Применять автоматически</span>
<Toggle checked={settings.autoApply} onChange={v => set("autoApply", v)} />
<FormToggle checked={settings.autoApply} onChange={v => set("autoApply", v)} />
</div>
{settings.autoApply && (
<>
@@ -1264,51 +937,14 @@ export default function RouteOptimizerPage() {
: "нет данных"}
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/30">
{["Интерфейс", "Cost", "Score", "Ping", "Speed (dl/ul)"].map((h) => (
<th key={h} className="text-left px-3 py-1.5 font-medium text-muted-foreground">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{ospfPreviewError && (
<tr>
<td colSpan={5} className="px-3 py-2 text-destructive">
Ошибка preview: {ospfPreviewError}
</td>
</tr>
)}
{!ospfPreviewLoading && !ospfPreviewError && (ospfPreview?.interfaces.length ?? 0) === 0 && (
<tr>
<td colSpan={5} className="px-3 py-2 text-muted-foreground">
Интерфейсы OSPF не найдены для выбранного сервера.
</td>
</tr>
)}
{(ospfPreview?.interfaces ?? []).map((row) => (
<tr key={`${row.interface}-${row.currentCost}-${row.optimalCost}`}>
<td className="px-3 py-1.5 font-mono">{row.interface}</td>
<td className="px-3 py-1.5 font-mono">
<span className="text-sky-600 dark:text-sky-400">{row.currentCost}</span>
{" → "}
<span className={row.currentCost === row.optimalCost
? "text-emerald-600 dark:text-emerald-400"
: "text-amber-600 dark:text-amber-400"}
>
{row.optimalCost}
</span>
</td>
<td className="px-3 py-1.5 font-mono">{row.score}</td>
<td className="px-3 py-1.5 font-mono">{row.pingMs}ms</td>
<td className="px-3 py-1.5 font-mono">{row.dlMbps} / {row.ulMbps}</td>
</tr>
))}
</tbody>
</table>
</div>
<RouteOptimizerOspfPreviewDataGrid
rows={(ospfPreview?.interfaces ?? []).map((row) => ({
id: `${row.interface}-${row.currentCost}-${row.optimalCost}`,
...row,
}))}
error={ospfPreviewError || null}
loading={ospfPreviewLoading}
/>
</div>
{ospfApplyResult && (
+157 -469
View File
@@ -1,8 +1,13 @@
"use client"
import { Fragment, useEffect, useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { StatusBadge } from "@/components/status-badge"
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"
import type { Filter } from "@/components/reui/filters"
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
import { SERVER_FILTER_ACCESSORS, SERVER_FILTER_FIELDS } from "@/lib/data-filters/server-filter-fields"
import { servers as initialServers } from "@/lib/data"
import type { ServerType, Server, WanUplink } from "@/lib/data"
import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers"
@@ -23,6 +28,7 @@ import { useDataSource } from "@/lib/data-source"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { Card, CardContent } from "@/components/ui/card"
import { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@@ -30,56 +36,25 @@ import {
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
} from "@/components/ui/dropdown-menu"
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
import {
SearchIcon, RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
MoreHorizontalIcon, EyeIcon, EyeOffIcon,
RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
EyeIcon, EyeOffIcon,
ChevronRightIcon, ChevronDownIcon,
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
ShieldIcon, WifiIcon, PencilIcon, PowerIcon, Trash2Icon, ExternalLinkIcon,
ShieldIcon, WifiIcon,
HomeIcon, ServerIcon, NetworkIcon,
} from "lucide-react"
// ─── RouterOS version utilities ───────────────────────────────────────────────
/** Numeric version score: "7.20.1 (stable)" → 720, "7.14.2" → 714, "7.9" → 709 */
function rosVer(os: string): number {
const m = os.match(/(\d+)\.(\d+)/)
if (!m) return 0
return parseInt(m[1], 10) * 100 + parseInt(m[2], 10)
}
interface RosFeature { name: string; minVer: number; label: string; desc: string }
const ROS_FEATURES: RosFeature[] = [
{ name: "WireGuard", minVer: 701, label: "7.1+", desc: "WireGuard VPN туннели" },
{ name: "Container", minVer: 704, label: "7.4+", desc: "Docker-совместимые контейнеры" },
{ name: "BFD", minVer: 705, label: "7.5+", desc: "Bidirectional Forwarding Detection" },
{ name: "Large Communities", minVer: 707, label: "7.7+", desc: "BGP Large Communities (RFC 8092)" },
{ name: "VXLAN", minVer: 710, label: "7.10+", desc: "VXLAN overlay туннели" },
{ name: "RPKI", minVer: 713, label: "7.13+", desc: "Route Origin Validation" },
{ name: "BGP Flowspec", minVer: 714, label: "7.14+", desc: "BGP Flow Spec (RFC 8955)" },
{ name: "IPv6 Firewall", minVer: 715, label: "7.15+", desc: "Расширенный IPv6 Firewall" },
{ name: "REST API v2", minVer: 716, label: "7.16+", desc: "Обновлённый REST API" },
{ name: "VRF Enhanced", minVer: 717, label: "7.17+", desc: "Расширенная поддержка VRF" },
]
function RosBadge({ os }: { os: string }) {
const v = rosVer(os)
const cls = v >= 715
? "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/20"
: v >= 710
? "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/20"
: "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/20"
return (
<span className={cn("text-xs font-mono border rounded px-2 py-0.5", cls)}>
{os}
</span>
)
}
// ─── Countries ───────────────────────────────────────────────────────────────
const COUNTRIES = [
@@ -97,90 +72,8 @@ const COUNTRIES = [
{ code: "NO", label: "Норвегия" },
]
// ─── Type config ─────────────────────────────────────────────────────────────
const TYPE_LABELS: Record<ServerType, string> = {
"jump-host": "JumpHost",
"exit-node": "Exit Node",
"home-router": "Home Router",
}
const TYPE_STYLES: Record<ServerType, string> = {
"jump-host": "bg-violet-500/10 text-violet-400 border-violet-500/20",
"exit-node": "bg-sky-500/10 text-sky-400 border-sky-500/20",
"home-router": "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
}
const TYPE_ICONS: Record<ServerType, React.ReactNode> = {
"jump-host": <ServerIcon className="size-3 mr-1" />,
"exit-node": <NetworkIcon className="size-3 mr-1" />,
"home-router": <HomeIcon className="size-3 mr-1" />,
}
function TypeBadge({ type }: { type: ServerType }) {
return (
<span className={cn(
"inline-flex items-center text-xs font-medium border rounded px-2 py-0.5",
TYPE_STYLES[type],
)}>
{TYPE_ICONS[type]}{TYPE_LABELS[type]}
</span>
)
}
// ─── Shared small components ──────────────────────────────────────────────────
function Field({ label, hint, required, children }: {
label: string; hint?: string; required?: boolean; children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button type="button" role="switch" aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
checked ? "bg-primary" : "bg-input")}>
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0")} />
</button>
)
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 py-0.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
function SegmentedControl<T extends string>({ value, onChange, options }: {
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
}) {
return (
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
{options.map((o) => (
<button key={o.value} type="button" onClick={() => onChange(o.value)}
className={cn("px-3 py-1 text-sm rounded transition-colors",
value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground")}>
{o.label}
</button>
))}
</div>
)
}
// ─── Country field ────────────────────────────────────────────────────────────
function CountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
@@ -267,30 +160,30 @@ function WanUplinkEditor({ wans, onChange }: {
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="Имя" required>
<FormField label="Имя" required>
<Input className="h-8 font-mono text-xs" placeholder="WAN1-RT"
value={wan.name} onChange={e => updateWan(wan.id, { name: e.target.value })} />
</Field>
<Field label="Интерфейс">
</FormField>
<FormField label="Интерфейс">
<Input className="h-8 font-mono text-xs" placeholder="ether1"
value={wan.iface} onChange={e => updateWan(wan.id, { iface: e.target.value })} />
</Field>
<Field label="Провайдер (ISP)">
</FormField>
<FormField label="Провайдер (ISP)">
<Input className="h-8 text-xs" placeholder="Rostelecom"
value={wan.isp} onChange={e => updateWan(wan.id, { isp: e.target.value })} />
</Field>
<Field label="Внешний IP">
</FormField>
<FormField label="Внешний IP">
<Input className="h-8 font-mono text-xs" placeholder="94.25.168.1"
value={wan.ip} onChange={e => updateWan(wan.id, { ip: e.target.value })} />
</Field>
<Field label="↓ Макс. Мбит">
</FormField>
<FormField label="↓ Макс. Мбит">
<Input className="h-8 font-mono text-xs" type="number" min={1}
value={wan.maxDl} onChange={e => updateWan(wan.id, { maxDl: Number(e.target.value) })} />
</Field>
<Field label="↑ Макс. Мбит">
</FormField>
<FormField label="↑ Макс. Мбит">
<Input className="h-8 font-mono text-xs" type="number" min={1}
value={wan.maxUl} onChange={e => updateWan(wan.id, { maxUl: Number(e.target.value) })} />
</Field>
</FormField>
</div>
</div>
))}
@@ -328,10 +221,11 @@ export default function ServersPage() {
const [_backendOk, setBackendOk] = useState(false)
const [search, setSearch] = useState("")
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
const [open, setOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<SheetMode>("add")
const [editingId, setEditingId] = useState<string | null>(null)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [sheetStep, setSheetStep] = useState(1)
const [form, setForm] = useState<FormState>(defaultForm)
const [testState, setTestState] = useState<TestState>("idle")
const [testMsg, setTestMsg] = useState("")
@@ -368,6 +262,7 @@ export default function ServersPage() {
function openAdd() {
setSheetMode("add"); setEditingId(null)
setForm(defaultForm); setTestState("idle"); setTestMsg("")
setSheetStep(1)
setOpen(true)
}
@@ -381,7 +276,7 @@ export default function ServersPage() {
lanSubnet: s.lanSubnet ?? "",
wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [],
})
setTestState("idle"); setTestMsg(""); setOpen(true)
setTestState("idle"); setTestMsg(""); setSheetStep(1); setOpen(true)
// Fetch full server details (including credentials) from backend
if (isLive) {
@@ -549,13 +444,14 @@ export default function ServersPage() {
// ── derived ──────────────────────────────────────────────────────────────
const filtered = useMemo(() => {
return serverList.filter(sv => {
const base = serverList.filter(sv => {
if (typeFilter !== "all" && sv.type !== typeFilter) return false
if (!search) return true
const q = search.toLowerCase()
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
})
}, [serverList, search, typeFilter])
return applyReuiFilters(base, advancedFilters, SERVER_FILTER_ACCESSORS)
}, [serverList, search, typeFilter, advancedFilters])
const counts = useMemo(() => ({
all: serverList.length,
@@ -615,273 +511,35 @@ export default function ServersPage() {
</div>
{/* Table */}
<Card>
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{tabs.map(tab => (
<button key={tab.value} onClick={() => setTypeFilter(tab.value)}
className={cn(
"flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors",
typeFilter === tab.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}>
{tab.label}
<span className="text-xs tabular-nums opacity-60">
{tab.value === "all" ? counts.all : counts[tab.value as ServerType]}
</span>
</button>
))}
</div>
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, хосту…" value={search} onChange={e => setSearch(e.target.value)} />
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} серверов</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3">Имя / Хост</th>
<th className="text-left font-medium px-4 py-3">Тип</th>
<th className="text-left font-medium px-4 py-3">Модель</th>
<th className="text-left font-medium px-4 py-3">RouterOS</th>
<th className="text-left font-medium px-4 py-3">Площадка</th>
<th className="text-left font-medium px-4 py-3">WAN / LAN</th>
<th className="text-right font-medium px-4 py-3">Задержка</th>
<th className="text-left font-medium px-4 py-3">Статус</th>
<th className="w-10 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map(s => {
const isExpanded = expandedId === s.id
const ver = rosVer(s.os)
return (
<Fragment key={s.id}>
<tr
className={cn(
"hover:bg-muted/40 transition-colors cursor-pointer",
isExpanded && "bg-muted/30",
)}
onClick={() => setExpandedId(prev => prev === s.id ? null : s.id)}
>
{/* Expand chevron + name */}
<td className="px-5 py-3">
<div className="flex items-start gap-2">
{isExpanded
? <ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
: <ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />}
<div className="min-w-0">
<p className="font-medium truncate">{s.name}</p>
<p className="text-xs font-mono text-muted-foreground">{s.host}</p>
{s.ipv6Address && (
<p className="text-[10px] font-mono text-sky-500/70 truncate max-w-[150px]" title={s.ipv6Address}>
{s.ipv6Address}
</p>
)}
</div>
</div>
</td>
<td className="px-4 py-3"><TypeBadge type={s.type} /></td>
<td className="px-4 py-3 text-muted-foreground text-xs">{s.model}</td>
<td className="px-4 py-3"><RosBadge os={s.os} /></td>
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
<Flag code={s.country} />
<span className="font-medium">{s.site}</span>
</div>
</td>
{/* WAN / LAN column */}
<td className="px-4 py-3">
{s.type === "home-router" && s.wanUplinks?.length ? (
<div className="flex flex-col gap-0.5">
{s.wanUplinks.map(w => (
<div key={w.id} className="flex items-center gap-1.5 text-[11px] font-mono">
<WifiIcon className="size-3 text-sky-400 shrink-0" />
<span className="font-semibold text-sky-600 dark:text-sky-400">{w.name}</span>
<span className="text-muted-foreground">{w.isp}</span>
<span className="text-muted-foreground">{w.maxDl}{w.maxUl}</span>
</div>
))}
{s.lanSubnet && (
<div className="text-[10px] font-mono text-muted-foreground mt-0.5">
LAN {s.lanSubnet}
</div>
)}
</div>
) : (
<div className="flex flex-col gap-0.5">
{s.wireGuardIfaces && s.wireGuardIfaces.length > 0 && (
<div className="text-[11px] font-mono text-violet-500 dark:text-violet-400 flex items-center gap-1">
<ShieldIcon className="size-3" />
WG: {s.wireGuardIfaces.length} iface · {s.wireGuardIfaces.reduce((n, i) => n + i.peers.length, 0)} peers
</div>
)}
{s.rpkiEnabled && (
<div className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400">RPKI </div>
)}
{!s.wireGuardIfaces?.length && !s.rpkiEnabled && (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
)}
</td>
<td className={cn("px-4 py-3 font-mono text-right text-sm",
s.latency == null ? "text-muted-foreground"
: s.latency > 60 ? "text-[var(--status-degraded-fg)]" : "")}>
{s.latency == null ? "—" : `${s.latency} мс`}
</td>
<td className="px-4 py-3"><StatusBadge status={s.status} /></td>
<td className="px-3 py-3" onClick={e => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7">
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuGroup>
<DropdownMenuLabel>{s.name}</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => window.open(`https://${s.host}`, "_blank")}>
<ExternalLinkIcon className="size-3.5" />Открыть WebFig
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openEdit(s)}>
<PencilIcon className="size-3.5" />Редактировать
</DropdownMenuItem>
{isLive && (
<DropdownMenuItem onClick={() => handlePoll(s.id)} disabled={pollingIds.has(s.id)}>
<RefreshCwIcon className={cn("size-3.5", pollingIds.has(s.id) && "animate-spin")} />
{pollingIds.has(s.id) ? "Опрос…" : "Опросить"}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => handleToggleStatus(s.id)}>
<PowerIcon className="size-3.5" />
{s.status === "offline" ? "Включить" : "Отключить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => handleDelete(s.id)}>
<Trash2Icon className="size-3.5" />Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
{/* ── Expandable detail row ── */}
{isExpanded && (
<tr className="bg-muted/20">
<td colSpan={9} className="px-8 py-5 border-b border-border/50">
<div className="flex flex-col gap-5">
{/* Snapshot / live data */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
{s.model && s.model !== "—" && (
<span className="text-muted-foreground">Модель: <span className="font-mono text-foreground">{s.model}</span></span>
)}
{s.uptime && (
<span className="text-muted-foreground">Uptime: <span className="font-mono text-foreground">{s.uptime}</span></span>
)}
{s.cpuLoad != null && (
<span className="text-muted-foreground">CPU: <span className={cn("font-mono font-semibold", s.cpuLoad > 80 ? "text-red-400" : s.cpuLoad > 50 ? "text-amber-400" : "text-emerald-400")}>{s.cpuLoad}%</span></span>
)}
{s.asn && (
<span className="text-muted-foreground">ASN: <span className="font-mono text-foreground">{s.asn}</span></span>
)}
{s.ipv6Address && (
<span className="text-muted-foreground">IPv6: <span className="font-mono text-sky-400">{s.ipv6Address}</span></span>
)}
{s.vrfNames?.map(v => (
<span key={v} className="text-muted-foreground">VRF: <span className="font-mono text-foreground">{v}</span></span>
))}
{s.comment && (
<span className="text-muted-foreground italic">{s.comment}</span>
)}
{s.polledAt && (
<span className="text-muted-foreground/50 text-[11px]">
Опрошен: {new Date(s.polledAt).toLocaleString("ru")}
</span>
)}
{!s.polledAt && (
<span className="text-amber-500/70 text-[11px]"> Ещё не опрашивался</span>
)}
</div>
{isLive && (
<Button
variant="outline" size="sm"
className="h-7 gap-1.5 text-xs shrink-0"
disabled={pollingIds.has(s.id)}
onClick={e => { e.stopPropagation(); handlePoll(s.id) }}
>
<RefreshCwIcon className={cn("size-3.5", pollingIds.has(s.id) && "animate-spin")} />
{pollingIds.has(s.id) ? "Опрос…" : "Опросить сейчас"}
</Button>
)}
</div>
{/* Feature matrix */}
<div>
<div className="flex items-center gap-3 mb-3">
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
Возможности RouterOS
</p>
<RosBadge os={s.os} />
<span className="text-[11px] text-muted-foreground">
{ver >= 715
? "✓ Актуальная версия — все ключевые фичи доступны"
: ver >= 710
? "⚠ Рекомендуется обновление до 7.15+"
: s.os !== "—"
? "✗ Устаревшая версия — требуется обновление"
: "Нет данных — нажмите «Опросить сейчас»"}
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
{ROS_FEATURES.map(f => {
const ok = ver >= f.minVer
return (
<div key={f.name} className={cn(
"flex items-start gap-2 rounded-md border px-3 py-2.5 transition-colors",
ok
? "border-emerald-500/25 bg-emerald-500/5"
: "border-border/40 bg-background/40 opacity-60",
)}>
{ok
? <CheckCircleIcon className="size-3.5 text-emerald-500 shrink-0 mt-0.5" />
: <XCircleIcon className="size-3.5 text-muted-foreground/40 shrink-0 mt-0.5" />}
<div className="min-w-0">
<p className={cn(
"text-xs font-medium leading-tight truncate",
ok ? "text-foreground" : "text-muted-foreground",
)}>
{f.name}
</p>
<p className="text-[10px] text-muted-foreground leading-tight mt-0.5">
{f.label} · {f.desc}
</p>
</div>
</div>
)
})}
</div>
</div>
</div>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
</Card>
<DataPageCard>
<DataPageToolbar
segmented={{
value: typeFilter,
onChange: setTypeFilter,
options: tabs.map((tab) => ({
value: tab.value,
label: tab.label,
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
})),
}}
filters={advancedFilters}
onFiltersChange={setAdvancedFilters}
filterFields={SERVER_FILTER_FIELDS}
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, хосту…"
countLabel={`${filtered.length} серверов`}
/>
<ServersDataGrid
servers={filtered}
isLive={isLive}
pollingIds={pollingIds}
onPoll={handlePoll}
onEdit={openEdit}
onDelete={handleDelete}
onToggleStatus={handleToggleStatus}
/>
</DataPageCard>
</div>
</div>
@@ -893,18 +551,45 @@ export default function ServersPage() {
<SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
<StepperItem step={1}>
<StepperTrigger>
<StepperIndicator>1</StepperIndicator>
<StepperTitle className="sr-only">Основные</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger>
<StepperIndicator>2</StepperIndicator>
<StepperTitle className="sr-only">WAN</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={3}>
<StepperTrigger>
<StepperIndicator>3</StepperIndicator>
<StepperTitle className="sr-only">API</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={4}>
<StepperTrigger>
<StepperIndicator>4</StepperIndicator>
<StepperTitle className="sr-only">Дополнительно</StepperTitle>
</StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
<StepperContent value={1} className="flex flex-col gap-4">
{/* 1. Основные */}
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<Field label="Имя сервера" required hint="Например home-msk-01">
<FormField label="Имя сервера" required hint="Например home-msk-01">
<Input className="font-mono" placeholder="home-msk-01"
value={form.name} onChange={e => set("name", e.target.value)} />
</Field>
</FormField>
<Field label="Тип узла" required>
<FormField label="Тип узла" required>
<SegmentedControl
value={form.type}
onChange={v => set("type", v)}
@@ -914,24 +599,24 @@ export default function ServersPage() {
{ value: "exit-node", label: "Exit Node" },
]}
/>
</Field>
</FormField>
<div className="grid grid-cols-2 gap-3">
<Field label="Площадка" required hint="MSK, SPB, FRA…">
<FormField label="Площадка" required hint="MSK, SPB, FRA…">
<Input className="font-mono uppercase" placeholder="MSK"
value={form.site} onChange={e => set("site", e.target.value.toUpperCase())} />
</Field>
</FormField>
{!isHomeRouter && (
<Field label="ASN" hint="Например AS65001">
<FormField label="ASN" hint="Например AS65001">
<Input className="font-mono" placeholder="AS65001"
value={form.asn} onChange={e => set("asn", e.target.value)} />
</Field>
</FormField>
)}
{isHomeRouter && (
<Field label="LAN-подсеть" hint="Например 192.168.10.0/24">
<FormField label="LAN-подсеть" hint="Например 192.168.10.0/24">
<Input className="font-mono" placeholder="192.168.10.0/24"
value={form.lanSubnet} onChange={e => set("lanSubnet", e.target.value)} />
</Field>
</FormField>
)}
</div>
@@ -939,45 +624,43 @@ export default function ServersPage() {
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Включён</span>
<Toggle checked={form.enabled} onChange={v => set("enabled", v)} />
<FormToggle checked={form.enabled} onChange={v => set("enabled", v)} />
</div>
</div>
{/* 2. WAN-аплинки (только для home-router) */}
{isHomeRouter && (
<div className="flex flex-col gap-4">
</StepperContent>
<StepperContent value={2} className="flex flex-col gap-4">
<SectionTitle>WAN-аплинки</SectionTitle>
{!isHomeRouter ? (
<p className="text-sm text-muted-foreground">WAN-аплинки доступны только для типа Home Router.</p>
) : (
<WanUplinkEditor
wans={form.wanUplinks}
onChange={wans => set("wanUplinks", wans)}
/>
</div>
)}
{/* 3. Подключение (API) */}
<div className="flex flex-col gap-4">
)}
</StepperContent>
<StepperContent value={3} className="flex flex-col gap-4">
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>
<Field label="Хост / IP-адрес" required
<FormField label="Хост / IP-адрес" required
hint={isHomeRouter
? "Управляющий LAN-адрес роутера, например 192.168.10.1"
: "Внешний или управляющий IP-адрес роутера"}>
<Input className="font-mono" placeholder={isHomeRouter ? "192.168.10.1" : "203.0.113.1"}
value={form.host} onChange={e => set("host", e.target.value)} />
</Field>
</FormField>
<div className="grid grid-cols-2 gap-3">
<Field label="Протокол">
<FormField label="Протокол">
<SegmentedControl
value={form.proto}
onChange={v => { set("proto", v); set("port", v === "https" ? "443" : "80") }}
options={[{ value: "https", label: "HTTPS" }, { value: "http", label: "HTTP" }]}
/>
</Field>
<Field label="Порт" hint="443 / 80">
</FormField>
<FormField label="Порт" hint="443 / 80">
<Input className="font-mono" placeholder="443"
value={form.port} onChange={e => set("port", e.target.value)} />
</Field>
</FormField>
</div>
<div className="flex items-center justify-between">
@@ -988,13 +671,13 @@ export default function ServersPage() {
</p>
<p className="text-xs text-muted-foreground">Отключить для self-signed сертификатов</p>
</div>
<Toggle checked={form.verifySsl} onChange={v => set("verifySsl", v)} />
<FormToggle checked={form.verifySsl} onChange={v => set("verifySsl", v)} />
</div>
<Field label="Путь API">
<FormField label="Путь API">
<Input className="font-mono" placeholder="/rest"
value={form.apiPath} onChange={e => set("apiPath", e.target.value)} />
</Field>
</FormField>
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground mb-1">RouterOS 7.1+ REST API</p>
@@ -1008,12 +691,12 @@ export default function ServersPage() {
)}
</div>
<Field label="Имя пользователя" required hint="Пользователь RouterOS с доступом к API">
<FormField label="Имя пользователя" required hint="Пользователь RouterOS с доступом к API">
<Input className="font-mono" placeholder="api-user"
value={form.username} onChange={e => set("username", e.target.value)} />
</Field>
</FormField>
<Field label="Пароль" required>
<FormField label="Пароль" required>
<div className="relative">
<Input type={form.showPassword ? "text" : "password"}
className="font-mono pr-9" placeholder="Пароль пользователя RouterOS"
@@ -1024,7 +707,7 @@ export default function ServersPage() {
{form.showPassword ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
</button>
</div>
</Field>
</FormField>
<div className="flex flex-col gap-2">
<Button type="button" variant="outline" size="sm" className="w-fit gap-2"
@@ -1046,10 +729,8 @@ export default function ServersPage() {
</div>
)}
</div>
</div>
{/* 4. Дополнительно */}
<div className="flex flex-col gap-4">
</StepperContent>
<StepperContent value={4} className="flex flex-col gap-4">
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors">
{form.showAdvanced ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
@@ -1059,34 +740,41 @@ export default function ServersPage() {
{form.showAdvanced && (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<Field label="SSH-порт">
<FormField label="SSH-порт">
<Input type="number" className="font-mono" value={form.sshPort}
onChange={e => set("sshPort", Number(e.target.value))} />
</Field>
<Field label="Winbox-порт">
</FormField>
<FormField label="Winbox-порт">
<Input type="number" className="font-mono" value={form.winboxPort}
onChange={e => set("winboxPort", Number(e.target.value))} />
</Field>
</FormField>
</div>
<Field label="Таймаут соединения, с">
<FormField label="Таймаут соединения, с">
<Input type="number" className="font-mono" value={form.timeout}
onChange={e => set("timeout", Number(e.target.value))} />
</Field>
<Field label="Комментарий">
</FormField>
<FormField label="Комментарий">
<Input placeholder="Описание или заметка" value={form.comment}
onChange={e => set("comment", e.target.value)} />
</Field>
</FormField>
</div>
)}
</div>
</div>
</StepperContent>
</StepperPanel>
</Stepper>
<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" onClick={handleSave}>
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
</Button>
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
{sheetStep > 1 && (
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
)}
{sheetStep < 4 ? (
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
) : (
<Button className="ml-auto" onClick={handleSave}>
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
+65 -225
View File
@@ -1,8 +1,10 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
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"
@@ -24,6 +26,9 @@ import {
ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon, LoaderCircleIcon,
DownloadIcon, UploadIcon,
} from "lucide-react"
import { SubusersDataGrid } from "@/components/data-grids/subusers-data-grid"
import { SettingsAccessSummaryDataGrid } from "@/components/data-grids/settings-access-summary-data-grid"
import { DataPageCard } from "@/components/data-page-card"
import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client"
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
@@ -162,17 +167,6 @@ function makeApiFetch(backendUrl: string) {
// ─── small components ─────────────────────────────────────────────────────────
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button type="button" onClick={() => onChange(!checked)}
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
checked ? "bg-primary" : "bg-input")}>
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0")} />
</button>
)
}
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4 py-3.5">
@@ -413,24 +407,24 @@ function UserSheet({ open, user, onSave, onClose }: {
{tab === "profile" && (
<div className="px-5 py-5 flex flex-col gap-4">
<Field label="Полное имя" error={errors.name}>
<FormField label="Полное имя" error={errors.name}>
<Input value={form.name} onChange={e => setField("name", e.target.value)}
placeholder="Иван Иванов" className="h-9" />
</Field>
</FormField>
<Field label="Логин" error={errors.login}>
<FormField label="Логин" error={errors.login}>
<Input value={form.login} onChange={e => setField("login", e.target.value)}
placeholder="i.ivanov" className="h-9 font-mono" />
</Field>
</FormField>
<Field label="Email" error={errors.email}>
<FormField label="Email" error={errors.email}>
<Input value={form.email} onChange={e => setField("email", e.target.value)}
placeholder="i.ivanov@company.io" type="email" className="h-9" />
</Field>
</FormField>
<Separator />
<Field label="Роль">
<FormField label="Роль">
<div className="flex gap-2 flex-wrap">
{(["viewer", "operator", "admin"] as Role[]).map(r => (
<button key={r} type="button" onClick={() => setRole(r)}
@@ -453,18 +447,18 @@ function UserSheet({ open, user, onSave, onClose }: {
? "Управление инфраструктурой согласно выданным правам"
: "Только просмотр согласно выданным правам"}
</p>
</Field>
</FormField>
<Separator />
<Field label="Статус учётной записи">
<FormField label="Статус учётной записи">
<div className="flex items-center gap-3">
<Toggle checked={form.active} onChange={v => setField("active", v)} />
<FormToggle checked={form.active} onChange={v => setField("active", v)} />
<span className={cn("text-xs font-medium", form.active ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground")}>
{form.active ? "Активна" : "Заблокирована"}
</span>
</div>
</Field>
</FormField>
</div>
)}
@@ -577,100 +571,14 @@ function UserSheet({ open, user, onSave, onClose }: {
</p>
</div>
{/* table header */}
{form.subUsers.length > 0 && (
<div className="grid items-center gap-2 px-4 py-1.5 bg-muted/20 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"
style={{ gridTemplateColumns: "1fr 130px 120px 90px 36px 32px" }}>
<span>Логин / описание</span>
<span>Пароль</span>
<span>JH-серверы</span>
<span>IP-клиента</span>
<span />
<span />
</div>
)}
{/* rows */}
<div className="divide-y divide-border/60">
{form.subUsers.length === 0 && !addSubOpen && (
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
<CableIcon className="size-6 opacity-20" />
<p className="text-sm">Нет GRE-клиентов</p>
<p className="text-xs opacity-60">Добавьте учётки для подключения устройств</p>
</div>
)}
{form.subUsers.map(su => {
const jhs = servers.filter(s => su.jhServerIds.includes(s.id))
const revealed = revealedIds.has(su.id)
return (
<div key={su.id}
className={cn(
"grid items-center gap-2 px-4 py-2.5 hover:bg-muted/20 transition-colors",
!su.active && "opacity-50",
)}
style={{ gridTemplateColumns: "1fr 130px 120px 90px 36px 32px" }}>
{/* login + description */}
<div className="min-w-0">
<p className="text-xs font-mono font-medium truncate">{su.login}</p>
{su.description && (
<p className="text-[11px] text-muted-foreground truncate">{su.description}</p>
)}
{su.lastSeen && (
<p className="text-[10px] text-muted-foreground/50">{su.lastSeen}</p>
)}
</div>
{/* password */}
<div className="flex items-center gap-1 min-w-0">
<span className="font-mono text-[11px] truncate flex-1">
{revealed ? su.password : "••••••••••••"}
</span>
<button onClick={() => toggleReveal(su.id)}
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors">
{revealed
? <EyeOffIcon className="size-3" />
: <EyeIcon className="size-3" />}
</button>
<button onClick={() => navigator.clipboard.writeText(su.password).catch(() => {})}
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors">
<CopyIcon className="size-3" />
</button>
</div>
{/* JH servers */}
<div className="flex flex-wrap gap-1 min-w-0">
{jhs.length === 0
? <span className="text-[11px] text-muted-foreground/40"></span>
: jhs.map(jh => (
<span key={jh.id} className="inline-flex items-center gap-1 text-[10px] font-medium
bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20
rounded px-1 py-0.5">
<Flag code={jh.country} size={10} />
{jh.name.split("-").slice(-1)[0]}
</span>
))
}
</div>
{/* client IP */}
<span className="font-mono text-[11px] text-muted-foreground truncate">
{su.clientIp || "—"}
</span>
{/* active toggle */}
<Toggle checked={su.active} onChange={() => toggleSubUser(su.id)} />
{/* delete */}
<button onClick={() => removeSubUser(su.id)}
className="text-muted-foreground/40 hover:text-destructive transition-colors flex justify-end">
<TrashIcon className="size-3.5" />
</button>
</div>
)
})}
</div>
<SubusersDataGrid
subUsers={form.subUsers}
servers={servers}
revealedIds={revealedIds}
onToggleReveal={toggleReveal}
onToggleActive={toggleSubUser}
onRemove={removeSubUser}
/>
{/* inline add form */}
{addSubOpen ? (
@@ -775,22 +683,6 @@ function UserSheet({ open, user, onSave, onClose }: {
)
}
// ─── helper ───────────────────────────────────────────────────────────────────
function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">{label}</label>
{children}
{error && (
<p className="text-xs text-destructive flex items-center gap-1">
<AlertCircleIcon className="size-3" />{error}
</p>
)}
</div>
)
}
// ─── delete confirm ───────────────────────────────────────────────────────────
function DatabaseRestoreConfirm({
@@ -900,7 +792,7 @@ export default function SettingsPage() {
const [dbBackupBusy, setDbBackupBusy] = useState(false)
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
const dbRestoreInputRef = useRef<HTMLInputElement>(null)
const [dbRestoreDialogOpen, setDbRestoreDialogOpen] = useState(false)
// notifications
const [notifEmail, setNotifEmail] = useState(true)
@@ -1031,7 +923,6 @@ export default function SettingsPage() {
await restoreSystemDatabaseBackup(backendUrl, dbRestoreFile)
toast.success("База приложения восстановлена")
setDbRestoreFile(null)
if (dbRestoreInputRef.current) dbRestoreInputRef.current.value = ""
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось восстановить базу")
} finally {
@@ -1230,30 +1121,16 @@ export default function SettingsPage() {
description="Полностью заменяет текущую базу SQLite"
>
<div className="flex flex-col items-end gap-2">
<Input
ref={dbRestoreInputRef}
type="file"
accept=".db,.sqlite,.sqlite3,application/octet-stream"
className="hidden"
<Button
variant="outline"
size="sm"
className="h-8"
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
onChange={(e) => {
const file = e.target.files?.[0] ?? null
if (!file) return
setDbRestoreFile(file)
}}
/>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
onClick={() => dbRestoreInputRef.current?.click()}
>
<UploadIcon className="size-4" />
Выбрать файл
</Button>
</div>
onClick={() => setDbRestoreDialogOpen(true)}
>
<UploadIcon className="size-4" />
Выбрать файл
</Button>
{dbRestoreFile && (
<p className="text-xs text-muted-foreground max-w-[220px] text-right break-all">{dbRestoreFile.name}</p>
)}
@@ -1384,7 +1261,7 @@ export default function SettingsPage() {
label="Подставлять данные EvoBGP"
description="На страницах Домены, IP-диапазоны, ASN и Communities вместо моков из lib/data"
>
<Toggle
<FormToggle
checked={evoEnabledDraft}
onChange={(v) => setEvoEnabledDraft(v)}
/>
@@ -1496,15 +1373,15 @@ export default function SettingsPage() {
<CardHeader><CardTitle className="text-base">Каналы уведомлений</CardTitle></CardHeader>
<CardContent className="divide-y px-5">
<SettingRow label="Email" description="Отправка уведомлений на admin@routerlists.io">
<Toggle checked={notifEmail} onChange={setNotifEmail} />
<FormToggle checked={notifEmail} onChange={setNotifEmail} />
</SettingRow>
{notifEmail && <div className="py-3"><Input className="text-sm h-8" defaultValue="admin@routerlists.io" /></div>}
<SettingRow label="Slack" description="Webhook-интеграция с каналом #alerts">
<Toggle checked={notifSlack} onChange={setNotifSlack} />
<FormToggle checked={notifSlack} onChange={setNotifSlack} />
</SettingRow>
{notifSlack && <div className="py-3"><Input className="text-sm h-8 font-mono" placeholder="https://hooks.slack.com/…" /></div>}
<SettingRow label="Webhook" description="POST-запрос на произвольный endpoint">
<Toggle checked={notifWh} onChange={setNotifWh} />
<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>
@@ -1513,16 +1390,16 @@ export default function SettingsPage() {
<CardHeader><CardTitle className="text-base">Триггеры</CardTitle></CardHeader>
<CardContent className="divide-y px-5">
<SettingRow label="Деградация узла" description="Потери пакетов > 5% или RTT > 100мс">
<Toggle checked={notifDegr} onChange={setNotifDegr} />
<FormToggle checked={notifDegr} onChange={setNotifDegr} />
</SettingRow>
<SettingRow label="Узел ушёл offline">
<Toggle checked={notifOffline} onChange={setNotifOffline} />
<FormToggle checked={notifOffline} onChange={setNotifOffline} />
</SettingRow>
<SettingRow label="Падение BGP-сессии">
<Toggle checked={notifBgp} onChange={setNotifBgp} />
<FormToggle checked={notifBgp} onChange={setNotifBgp} />
</SettingRow>
<SettingRow label="Просроченный бэкап" description="Если последний бэкап старше 2 дней">
<Toggle checked={notifBackup} onChange={setNotifBackup} />
<FormToggle checked={notifBackup} onChange={setNotifBackup} />
</SettingRow>
</CardContent>
</Card>
@@ -1601,66 +1478,17 @@ export default function SettingsPage() {
</Card>
{/* access summary */}
<Card className="overflow-hidden gap-0 py-0">
<DataPageCard>
<div className="flex items-center gap-3 px-4 py-3 border-b">
<UserIcon className="size-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium">Сводка прав доступа</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-[11px] text-muted-foreground bg-muted/30">
<th className="text-left font-medium px-4 py-2">Пользователь</th>
<th className="text-left font-medium px-4 py-2">Разделы</th>
<th className="text-left font-medium px-4 py-2">Серверы</th>
<th className="text-left font-medium px-4 py-2">Права записи</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{users.map(u => {
const writeSections = u.role === "admin" ? ALL_SECTIONS : u.sections.filter(s => s.level === "write").map(s => s.section)
const readSections = u.role === "admin" ? [] : u.sections.filter(s => s.level === "read").map(s => s.section)
const accessServers = u.role === "admin" ? servers : servers.filter(s => u.servers.find(p => p.serverId === s.id && p.level !== "none"))
return (
<tr key={u.id} className="hover:bg-muted/20 transition-colors">
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<AvatarCircle avatar={u.avatar} active={u.active} />
<span className="text-sm font-medium">{u.name}</span>
</div>
</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">
{u.role === "admin"
? <span className="text-violet-600 dark:text-violet-400 font-medium">Все ({ALL_SECTIONS.length})</span>
: <span>{(readSections.length + writeSections.length)} из {ALL_SECTIONS.length}</span>}
</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">
{u.role === "admin"
? <span className="text-violet-600 dark:text-violet-400 font-medium">Все ({servers.length})</span>
: <span>{accessServers.length} из {servers.length}</span>}
</td>
<td className="px-4 py-2.5">
<div className="flex flex-wrap gap-1">
{u.role === "admin"
? <span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20">Полный доступ</span>
: writeSections.length === 0
? <span className="text-[10px] text-muted-foreground">Только просмотр</span>
: writeSections.slice(0, 3).map(s => (
<span key={s} className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">{s}</span>
))
}
{u.role !== "admin" && writeSections.length > 3 && (
<span className="text-[10px] text-muted-foreground">+{writeSections.length - 3}</span>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
<SettingsAccessSummaryDataGrid
users={users}
servers={servers}
allSectionsCount={ALL_SECTIONS.length}
/>
</DataPageCard>
</div>
)
@@ -1741,7 +1569,7 @@ export default function SettingsPage() {
<CardContent className="divide-y px-5">
<SettingRow label="Двухфакторная аутентификация (MFA)"
description="TOTP / Authenticator app для всех администраторов">
<Toggle checked={mfa} onChange={setMfa} />
<FormToggle checked={mfa} onChange={setMfa} />
</SettingRow>
<SettingRow label="Тайм-аут сессии (мин)" description="Автоматический выход при бездействии">
<Input className="w-20 h-8 text-sm" value={sessMin} onChange={e => setSessMin(e.target.value)} />
@@ -1763,7 +1591,7 @@ export default function SettingsPage() {
<CardContent className="divide-y px-5">
<SettingRow label="Расширенный журнал аудита"
description="Записывать все изменения конфигурации с указанием пользователя и IP">
<Toggle checked={auditLog} onChange={setAuditLog} />
<FormToggle checked={auditLog} onChange={setAuditLog} />
</SettingRow>
</CardContent>
</Card>
@@ -1853,7 +1681,6 @@ export default function SettingsPage() {
onCancel={() => {
if (dbRestoreBusy) return
setDbRestoreFile(null)
if (dbRestoreInputRef.current) dbRestoreInputRef.current.value = ""
}}
/>
)}
@@ -1864,6 +1691,19 @@ export default function SettingsPage() {
onCancel={() => setDeleteTarget(null)}
/>
)}
<FileImportDialog
open={dbRestoreDialogOpen}
onOpenChange={setDbRestoreDialogOpen}
title="Восстановление базы данных"
description="Выберите файл SQLite (.db) — текущая база будет полностью заменена"
accept=".db,.sqlite,.sqlite3,application/octet-stream"
onImport={async (files) => {
const file = files[0]
if (!file) return
setDbRestoreFile(file)
}}
/>
</div>
)
}
+51 -405
View File
@@ -2,6 +2,7 @@
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 { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -9,6 +10,12 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
import { Sparkline } from "@/components/sparkline"
import { DataPageCard } from "@/components/data-page-card"
import {
UptimeResourcesDataGrid,
type UptimeResourceRow,
} from "@/components/data-grids/uptime-resources-data-grid"
import { UptimeSpeedHistoryDataGrid } from "@/components/data-grids/uptime-speed-history-data-grid"
import { PING_PROBE_WARN_RTT_MS } from "@/lib/ping-probe"
import { cn } from "@/lib/utils"
import { servers as mockServers, pingProbes as INIT_PROBES, filters, type Server, type Filter } from "@/lib/data"
@@ -212,21 +219,6 @@ function probeGroupActionKey(srvId: string, group: { name: string; target: strin
// ── shared components ──────────────────────────────────────────────────────────
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button type="button" onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
checked ? "bg-primary" : "bg-input",
)}>
<span className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0",
)} />
</button>
)
}
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
return (
<span className={cn(
@@ -267,18 +259,6 @@ function StatChip({
)
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">
{label}
{hint && <span className="font-normal text-muted-foreground ml-1">{hint}</span>}
</label>
{children}
</div>
)
}
/** Активный интерфейс RouterOS: не disabled и running */
function isActiveRouterOsInterface(i: { running?: boolean; disabled?: boolean }): boolean {
return i.running === true && i.disabled !== true
@@ -323,36 +303,6 @@ function interfaceOptionMatchesSearch(iface: RouterInterfaceOption, raw: string)
return false
}
function SegmentedControl<T extends string>({
value,
onChange,
options,
}: {
value: T
onChange: (v: T) => void
options: Array<{ value: T; label: string }>
}) {
return (
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
"px-3 py-1 text-sm rounded transition-colors",
value === option.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
))}
</div>
)
}
function ServerPickerCards({
options,
selectedId,
@@ -910,74 +860,55 @@ function SortIcon({ k, sortKey, sortAsc }: { k: ResSortKey; sortKey: ResSortKey;
type ResTypeFilter = "all" | "jump-host" | "exit-node" | "home-router"
function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerResource[]; serversList: Server[]; liveApi?: boolean }) {
const [sortKey, setSortKey] = useState<ResSortKey>("name")
const [sortAsc, setSortAsc] = useState(true)
const [resSearch, setResSearch] = useState("")
const [typeFilter, setTypeFilter] = useState<ResTypeFilter>("all")
const rows = useMemo(() => resources.map((r) => {
const rows = useMemo((): UptimeResourceRow[] => resources.map((r) => {
const hasData = r.hasData !== false
const ramPct = hasData && r.ramTotal > 0 ? Math.round(r.ramUsed / r.ramTotal * 100) : 0
const hddPct = hasData && r.hddTotal > 0 ? Math.round(r.hddUsed / r.hddTotal * 100) : 0
return {
...r,
hasData,
server: serversList.find(s => s.id === r.serverId),
server: serversList.find(s => s.id === r.serverId)!,
ramPct,
hddPct,
}
}).filter(r => r.server !== undefined), [resources, serversList])
}).filter(r => serversList.some(s => s.id === r.serverId)), [resources, serversList])
// KPI aggregates (только серверы с реальными сэмплами за окно)
const onlineWithSamples = rows.filter(r => r.server!.status === "online" && r.hasData)
const onlineWithSamples = rows.filter(r => r.server.status === "online" && r.hasData)
const avgCpu = onlineWithSamples.length ? Math.round(onlineWithSamples.reduce((s, r) => s + r.cpu, 0) / onlineWithSamples.length) : 0
const avgRam = onlineWithSamples.length ? Math.round(onlineWithSamples.reduce((s, r) => s + r.ramPct, 0) / onlineWithSamples.length) : 0
const highCpu = rows.filter(r => r.server!.status === "online" && r.hasData && r.cpu >= 85).length
const highRam = rows.filter(r => r.server!.status === "online" && r.hasData && r.ramPct >= 85).length
const highHdd = rows.filter(r => r.server!.status === "online" && r.hasData && r.hddPct >= 85).length
const highCpu = rows.filter(r => r.server.status === "online" && r.hasData && r.cpu >= 85).length
const highRam = rows.filter(r => r.server.status === "online" && r.hasData && r.ramPct >= 85).length
const highHdd = rows.filter(r => r.server.status === "online" && r.hasData && r.hddPct >= 85).length
// Alerts
const alerts = useMemo(() =>
rows.filter(r => r.server!.status === "online" && r.hasData && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)),
rows.filter(r => r.server.status === "online" && r.hasData && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)),
[rows],
)
// Filtered + sorted
const visible = useMemo(() => {
let list = rows
if (typeFilter !== "all") list = list.filter(r => r.server!.type === typeFilter)
if (typeFilter !== "all") list = list.filter(r => r.server.type === typeFilter)
if (resSearch.trim()) {
const q = resSearch.toLowerCase()
list = list.filter(r =>
r.server!.name.toLowerCase().includes(q) ||
r.server!.site.toLowerCase().includes(q) ||
r.server.name.toLowerCase().includes(q) ||
r.server.site.toLowerCase().includes(q) ||
r.boardName.toLowerCase().includes(q)
)
}
list = [...list].sort((a, b) => {
let diff = 0
switch (sortKey) {
case "name": diff = a.server!.name.localeCompare(b.server!.name); break
case "cpu": diff = a.cpu - b.cpu; break
case "ram": diff = a.ramPct - b.ramPct; break
case "hdd": diff = a.hddPct - b.hddPct; break
case "uptime": diff = a.uptimeSeconds - b.uptimeSeconds; break
case "temp": diff = (a.temp ?? -1) - (b.temp ?? -1); break
}
return sortAsc ? diff : -diff
})
return list
}, [rows, typeFilter, resSearch, sortKey, sortAsc])
function toggleSort(k: ResSortKey) {
if (sortKey === k) setSortAsc(v => !v)
else { setSortKey(k); setSortAsc(false) } // default desc for metrics
}
}, [rows, typeFilter, resSearch])
function exportCsv() {
const header = ["Сервер", "Тип", "Площадка", "CPU %", "RAM %", "RAM использ.", "RAM всего", "HDD %", "HDD использ.", "HDD всего", "Uptime", "Температура °C", "RouterOS"]
const rowsCsv = visible.map(r => {
const s = r.server!
const s = r.server
return [s.name, s.type, s.site, r.cpu, r.ramPct, fmtMB(r.ramUsed), fmtMB(r.ramTotal),
r.hddPct, fmtMB(r.hddUsed), fmtMB(r.hddTotal), fmtUptime(r.uptimeSeconds),
r.temp ?? "", s.os].join(",")
@@ -1010,7 +941,7 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
<AlertDescription>
<div className="flex flex-wrap gap-1.5">
{alerts.map(r => {
const s = r.server!
const s = r.server
const issues: string[] = []
if (r.cpu >= 85) issues.push(`CPU ${r.cpu}%`)
if (r.ramPct >= 85) issues.push(`RAM ${r.ramPct}%`)
@@ -1091,195 +1022,9 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
</div>
{/* ── Table ─────────────────────────────────────────────────────────── */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/30 text-xs text-muted-foreground font-medium">
{/* Sortable: name */}
<th className="text-left px-5 py-3 cursor-pointer hover:text-foreground transition-colors select-none"
onClick={() => toggleSort("name")}>
<span className="flex items-center gap-0.5">
Сервер <SortIcon k="name" sortKey={sortKey} sortAsc={sortAsc} />
</span>
</th>
<th className="text-left px-4 py-3 hidden md:table-cell whitespace-nowrap">Модель · ROS</th>
{/* Sortable: cpu */}
<th className="text-left px-4 py-3 min-w-[160px] cursor-pointer hover:text-foreground transition-colors select-none"
onClick={() => toggleSort("cpu")}>
<span className="flex items-center gap-1.5">
<CpuIcon className="size-3.5" />CPU
<SortIcon k="cpu" sortKey={sortKey} sortAsc={sortAsc} />
</span>
</th>
{/* Sortable: ram */}
<th className="text-left px-4 py-3 min-w-[175px] cursor-pointer hover:text-foreground transition-colors select-none"
onClick={() => toggleSort("ram")}>
<span className="flex items-center gap-1.5">
<HardDriveIcon className="size-3.5" />RAM
<SortIcon k="ram" sortKey={sortKey} sortAsc={sortAsc} />
</span>
</th>
{/* Sortable: hdd */}
<th className="text-left px-4 py-3 min-w-[175px] cursor-pointer hover:text-foreground transition-colors select-none"
onClick={() => toggleSort("hdd")}>
<span className="flex items-center gap-1.5">
<HardDriveIcon className="size-3.5" />Диск
<SortIcon k="hdd" sortKey={sortKey} sortAsc={sortAsc} />
</span>
</th>
{/* Sortable: uptime */}
<th className="text-left px-4 py-3 cursor-pointer hover:text-foreground transition-colors select-none"
onClick={() => toggleSort("uptime")}>
<span className="flex items-center gap-1.5">
<ClockIcon className="size-3.5" />Uptime
<SortIcon k="uptime" sortKey={sortKey} sortAsc={sortAsc} />
</span>
</th>
{/* Sortable: temp */}
<th className="text-left px-4 py-3 cursor-pointer hover:text-foreground transition-colors select-none"
onClick={() => toggleSort("temp")}>
<span className="flex items-center gap-1.5">
<ThermometerIcon className="size-3.5" />°C
<SortIcon k="temp" sortKey={sortKey} sortAsc={sortAsc} />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{visible.length === 0 && (
<tr>
<td colSpan={7} className="text-center text-sm text-muted-foreground py-12">
<SearchIcon className="size-6 mx-auto mb-2 opacity-20" />
Ничего не найдено
</td>
</tr>
)}
{visible.map(r => {
const srv = r.server!
const offline = srv.status !== "online"
const hasSamples = r.hasData !== false
const noMetrics = offline || !hasSamples
const isCrit = !noMetrics && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)
const cpuColor = r.cpu >= 85 ? "hsl(0 84% 60%)" : r.cpu >= 70 ? "hsl(38 92% 50%)" : "hsl(142 76% 36%)"
return (
<tr key={r.serverId} className={cn(
"hover:bg-muted/30 transition-colors",
offline && "opacity-50",
isCrit && "bg-red-500/3",
)}>
{/* Server */}
<td className="px-5 py-3">
<div className="flex items-center gap-2 flex-wrap">
{isCrit && <AlertCircleIcon className="size-3.5 text-red-500 shrink-0" />}
{!isCrit && <StatusDot status={srv.status} pulse={!offline} />}
<Flag code={srv.country} size={16} />
<span className="font-mono font-semibold">{srv.name}</span>
<TypeChip type={srv.type} />
<span className="text-xs text-muted-foreground hidden xl:inline">{srv.site}</span>
{!offline && r.hasData === false && (
<span className="text-[10px] rounded border border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400 px-1.5 py-0.5">
нет данных
</span>
)}
</div>
</td>
{/* Board + ROS */}
<td className="px-4 py-3 hidden md:table-cell">
<div className="flex flex-col leading-tight">
<span className="font-mono text-xs text-muted-foreground">{hasSamples ? r.boardName : "—"}</span>
<span className="text-[10px] text-muted-foreground/50">{srv.os}</span>
</div>
</td>
{/* CPU */}
<td className="px-4 py-3">
{noMetrics
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
: (
<div className="flex flex-col gap-1.5 min-w-[140px]">
<div className="flex items-center gap-2">
<span className={cn("font-mono text-sm font-semibold tabular-nums w-10 shrink-0", resPctColor(r.cpu))}>
{r.cpu}%
</span>
<MiniBar pct={r.cpu} className="flex-1" />
</div>
<Sparkline data={r.cpuHistory} width={120} height={18} color={cpuColor} filled />
</div>
)}
</td>
{/* RAM */}
<td className="px-4 py-3">
{noMetrics
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
: (
<div className="flex flex-col gap-1.5 min-w-[155px]">
<div className="flex items-center justify-between text-xs">
<span className={cn("font-mono font-semibold", resPctColor(r.ramPct))}>{r.ramPct}%</span>
<span className="text-muted-foreground/60 font-mono text-[10px]">
{fmtMB(r.ramUsed)}/{fmtMB(r.ramTotal)}
</span>
</div>
<MiniBar pct={r.ramPct} />
</div>
)}
</td>
{/* HDD */}
<td className="px-4 py-3">
{noMetrics
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
: (
<div className="flex flex-col gap-1.5 min-w-[155px]">
<div className="flex items-center justify-between text-xs">
<span className={cn("font-mono font-semibold", resPctColor(r.hddPct))}>{r.hddPct}%</span>
<span className="text-muted-foreground/60 font-mono text-[10px]">
{fmtMB(r.hddUsed)}/{fmtMB(r.hddTotal)}
</span>
</div>
<MiniBar pct={r.hddPct} />
</div>
)}
</td>
{/* Uptime */}
<td className="px-4 py-3">
<span className="font-mono text-xs text-muted-foreground">
{noMetrics ? (offline ? "—" : "—") : fmtUptime(r.uptimeSeconds)}
</span>
</td>
{/* Temp */}
<td className="px-4 py-3">
{r.temp !== undefined && !noMetrics ? (
<span className={cn("font-mono text-sm font-semibold tabular-nums",
r.temp >= 70 ? "text-red-600 dark:text-red-400"
: r.temp >= 55 ? "text-amber-600 dark:text-amber-400"
: "text-muted-foreground",
)}>
{r.temp}°C
</span>
) : (
<span className="text-muted-foreground/30 text-xs"></span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
<DataPageCard>
<UptimeResourcesDataGrid rows={visible} />
</DataPageCard>
<p className="text-xs text-muted-foreground/40 text-center">
{liveApi
@@ -2401,7 +2146,7 @@ export default function UptimePage() {
className={cn("px-4 py-3 hover:bg-muted/20 transition-colors", !probe.enabled && "opacity-50")}>
<div className="flex items-center gap-3 flex-wrap">
{/* enable toggle */}
<Toggle checked={probe.enabled} onChange={(v) => updateSpeedProbe(probe.id, { enabled: v })} />
<FormToggle checked={probe.enabled} onChange={(v) => updateSpeedProbe(probe.id, { enabled: v })} />
{/* route: src → dst */}
<div className="flex items-center gap-1.5 min-w-0 flex-1">
@@ -2534,106 +2279,7 @@ export default function UptimePage() {
<span className="text-xs text-muted-foreground">{speedRuns.length} запусков</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/40 text-muted-foreground">
{["Время", "Маршрут", "Параметры", "Статус", "TX avg", "RX avg", "Ping после BT"].map(h => (
<th key={h} className="px-4 py-2.5 text-left font-medium whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{speedRuns.map((run) => {
const src = allServers.find((s) => s.id === run.srcServerId)
const dst = allServers.find((s) => s.id === run.dstServerId)
const maxVal = Math.max(run.txAvgMbps, run.rxAvgMbps, 1)
return (
<tr key={run.id} className="hover:bg-muted/20 transition-colors">
<td className="px-4 py-2.5 text-muted-foreground whitespace-nowrap tabular-nums font-mono">
{new Date(run.startedAt).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit", day: "2-digit", month: "2-digit" })}
</td>
<td className="px-4 py-2.5 font-mono whitespace-nowrap">
<div className="flex items-center gap-1.5">
<Flag code={src?.country ?? "UN"} size={13} />
<span>{src?.name ?? run.srcServerId}</span>
<ArrowRightIcon className="size-3 text-muted-foreground" />
<Flag code={dst?.country ?? "UN"} size={13} />
<span>{dst?.name ?? run.dstServerId}</span>
</div>
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
{run.srcInterfaceAddress && run.dstInterfaceAddress
? `${run.srcInterfaceAddress}${run.dstInterfaceAddress}`
: "внутренние IP: auto/не указаны"}
</div>
</td>
<td className="px-4 py-2.5 text-muted-foreground whitespace-nowrap">
<div className="flex items-center gap-1">
<span className="inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold bg-muted/60 border-border/60">
{run.protocol.toUpperCase()}
</span>
<span className="text-muted-foreground/60">·</span>
<span>{run.direction}</span>
<span className="text-muted-foreground/60">·</span>
<span>{run.durationSec}s</span>
</div>
</td>
<td className="px-4 py-2.5">
{run.status === "running" ? (
<span className="inline-flex items-center gap-1 text-[var(--status-degraded-fg)]">
<RefreshCwIcon className="size-3 animate-spin" />running
</span>
) : run.status === "error" ? (
<span className="text-[var(--status-offline-fg)]">error</span>
) : (
<span className="text-[var(--status-online-fg)]">done</span>
)}
</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2 min-w-[120px]">
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
<div className="h-full rounded-full bg-[var(--chart-tx)]"
style={{ width: `${(run.txAvgMbps / maxVal) * 100}%` }} />
</div>
<span className="font-mono tabular-nums text-[var(--chart-tx)] font-medium whitespace-nowrap">
{run.txAvgMbps} Мбит/с
</span>
</div>
</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2 min-w-[120px]">
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
<div className="h-full rounded-full bg-[var(--chart-rx)]"
style={{ width: `${(run.rxAvgMbps / maxVal) * 100}%` }} />
</div>
<span className="font-mono tabular-nums text-[var(--chart-rx)] font-medium whitespace-nowrap">
{run.rxAvgMbps} Мбит/с
</span>
</div>
</td>
<td className="px-4 py-2.5 font-mono tabular-nums whitespace-nowrap">
{run.status !== "done" ? "—" : run.afterBtPing?.error ? (
<span className="text-[var(--status-offline-fg)]" title={run.afterBtPing.error}>
ошибка
</span>
) : run.afterBtPing?.rttMs != null ? (
<span className="text-violet-600 dark:text-violet-400">
{run.afterBtPing.rttMs} мс
{run.afterBtPing.lossPct != null && run.afterBtPing.lossPct > 0 && (
<span className="text-amber-600 dark:text-amber-400"> · {run.afterBtPing.lossPct}%</span>
)}
</span>
) : (
<span className="text-amber-600 dark:text-amber-400">
timeout
{run.afterBtPing?.lossPct != null && <span> · {run.afterBtPing.lossPct}%</span>}
</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
<UptimeSpeedHistoryDataGrid runs={speedRuns} servers={allServers} />
</div>
</Card>
)}
@@ -2824,7 +2470,7 @@ export default function UptimePage() {
!p.enabled && "opacity-40",
)}
style={{ gridTemplateColumns: "36px 16px 130px 120px 140px 70px 44px minmax(132px,1fr) 96px 36px 72px" }}>
<Toggle checked={p.enabled} onChange={v => toggleProbe(p.id, v)} />
<FormToggle checked={p.enabled} onChange={v => toggleProbe(p.id, v)} />
<StatusDot
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
pulse={p.status === "up" && p.enabled}
@@ -3002,7 +2648,7 @@ export default function UptimePage() {
</SheetHeader>
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-5">
<Field label="Источник">
<FormField label="Источник">
<ServerPickerCards
options={selectableSources}
selectedId={speedDraft.srcServerId}
@@ -3015,9 +2661,9 @@ export default function UptimePage() {
void loadSpeedInterfaces(nextSrc)
}}
/>
</Field>
</FormField>
<Field label="Назначение">
<FormField label="Назначение">
<ServerPickerCards
options={selectableSources.filter((s) => s.id !== speedDraft.srcServerId)}
selectedId={speedDraft.dstServerId}
@@ -3026,28 +2672,28 @@ export default function UptimePage() {
void loadSpeedInterfaces(nextDst)
}}
/>
</Field>
</FormField>
<Field label="Интерфейс источника">
<FormField label="Интерфейс источника">
<InterfacePickerCards
value={speedDraft.srcInterface}
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, srcInterface: v }))}
options={filterActiveInterfaces(speedIfaces[speedDraft.srcServerId] ?? [])}
autoLabel="auto"
/>
</Field>
</FormField>
<Field label="Интерфейс назначения">
<FormField label="Интерфейс назначения">
<InterfacePickerCards
value={speedDraft.dstInterface}
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, dstInterface: v }))}
options={filterActiveInterfaces(speedIfaces[speedDraft.dstServerId] ?? [])}
autoLabel="auto"
/>
</Field>
</FormField>
<div className="grid grid-cols-3 gap-3">
<Field label="Протокол">
<FormField label="Протокол">
<SegmentedControl
value={speedDraft.protocol}
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, protocol: v }))}
@@ -3056,8 +2702,8 @@ export default function UptimePage() {
{ value: "udp", label: "UDP" },
]}
/>
</Field>
<Field label="Direction">
</FormField>
<FormField label="Direction">
<SegmentedControl
value={speedDraft.direction}
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, direction: v }))}
@@ -3067,10 +2713,10 @@ export default function UptimePage() {
{ value: "receive", label: "rx" },
]}
/>
</Field>
<Field label="Сек">
</FormField>
<FormField label="Сек">
<Input className="h-9" value={speedDraft.durationSec} onChange={(e) => setSpeedDraft((prev) => ({ ...prev, durationSec: e.target.value }))} />
</Field>
</FormField>
</div>
{speedDraft.srcServerId &&
@@ -3169,7 +2815,7 @@ export default function UptimePage() {
</div>
</div>
<Field
<FormField
label="Источник (кто пингует)"
hint="— весь каталог, в т.ч. выключенные в инвентаре (Home Router часто «выкл.», но доступен по LAN для ping)"
>
@@ -3178,9 +2824,9 @@ export default function UptimePage() {
selectedId={newSrcId}
onSelect={(id) => setNewSrcId(id)}
/>
</Field>
</FormField>
<Field label="Интерфейс источника" hint="(необязательно)">
<FormField label="Интерфейс источника" hint="(необязательно)">
<InterfacePickerCards
value={newSrcInterface}
onChange={setNewSrcInterface}
@@ -3188,25 +2834,25 @@ export default function UptimePage() {
autoLabel="авто (по маршруту)"
busy={srcInterfacesBusy}
/>
</Field>
</FormField>
<Field label="Имя пробы">
<FormField label="Имя пробы">
<Input className="h-9 text-sm" placeholder="youtube.com"
value={newName} onChange={e => setNewName(e.target.value)} />
</Field>
</FormField>
<Field label="Целевой IP / хост">
<FormField label="Целевой IP / хост">
<Input className="h-9 text-sm font-mono" placeholder="142.250.74.110"
value={newTarget} onChange={e => setNewTarget(e.target.value)} />
</Field>
</FormField>
<Field label="Связанный фильтр" hint="(необязательно)">
<FormField label="Связанный фильтр" hint="(необязательно)">
<LinkedFilterPickerCards
value={newFilter}
onChange={setNewFilter}
items={filters}
/>
</Field>
</FormField>
</div>
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
+18 -140
View File
@@ -4,17 +4,14 @@ import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { vxlanTunnels, servers } from "@/lib/data"
import type { VxlanTunnel } from "@/lib/data"
import { Flag } from "@/components/flag"
import { Card, CardContent } from "@/components/ui/card"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"
import {
SearchIcon, NetworkIcon, PlusIcon, MoreHorizontalIcon,
Trash2Icon, PencilIcon, PowerIcon, CopyIcon, CheckIcon,
NetworkIcon, PlusIcon, CopyIcon, CheckIcon,
CodeXmlIcon, LayersIcon,
} from "lucide-react"
import {
@@ -125,100 +122,7 @@ function ExportSheet({ open, tunnel, onClose }: {
)
}
// ─── Tunnel row ───────────────────────────────────────────────────────────────
function TunnelRow({
tunnel,
onExport,
}: {
tunnel: VxlanTunnel
onExport: () => void
}) {
const srv = serverFor(tunnel.serverId)
return (
<div className={cn(
"grid grid-cols-[10px_1fr_1fr_auto_auto_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-3 items-center border-b last:border-b-0 hover:bg-muted/30 transition-colors",
!tunnel.enabled && "opacity-50",
)}>
{/* status dot */}
<span className={cn(
"size-2 rounded-full shrink-0",
tunnel.status === "up" ? "bg-emerald-500" : "bg-red-500",
)} />
{/* name */}
<div className="min-w-0">
<p className="font-mono font-medium text-sm truncate">{tunnel.name}</p>
<p className="text-[11px] text-muted-foreground font-mono">VTEP: {tunnel.vtepIp}</p>
</div>
{/* server */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
{srv && <><Flag code={srv.country} size={12} /><span className="font-mono truncate">{srv.name}</span></>}
</div>
{/* VNI */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">VNI</p>
<p className="font-mono text-sm">{tunnel.vni}</p>
</div>
{/* Port */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Port</p>
<p className="font-mono text-sm">{tunnel.dstPort}</p>
</div>
{/* Remote VTEPs */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Remote VTEP</p>
<p className="font-mono text-sm">{tunnel.remoteVteps.length}</p>
</div>
{/* ARP Proxy */}
<span className={cn("text-[10px] font-mono px-1.5 py-0.5 rounded",
tunnel.arpProxy ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : "bg-muted text-muted-foreground")}>
ARP {tunnel.arpProxy ? "✓" : "✗"}
</span>
{/* MAC learning */}
<span className={cn("text-[10px] font-mono px-1.5 py-0.5 rounded",
tunnel.macLearning ? "bg-sky-500/10 text-sky-600 dark:text-sky-400" : "bg-muted text-muted-foreground")}>
MAC {tunnel.macLearning ? "✓" : "✗"}
</span>
{/* Status badge */}
<span className={cn(
"text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap",
tunnel.status === "up"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-red-500/10 text-red-500 border-red-500/20",
)}>
{tunnel.status === "up" ? "UP" : "DOWN"}
</span>
{/* menu */}
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7">
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem><PowerIcon className="size-4" />{tunnel.enabled ? "Отключить" : "Включить"}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
// ════════════════════════════════════════════════════════════════════════════
// ─── Export Sheet ─────────────────────────────────────────────────────────────
export default function VxlanPage() {
const [search, setSearch] = useState("")
const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null)
@@ -284,45 +188,19 @@ export default function VxlanPage() {
</div>
{/* Table */}
<Card>
<div className="flex items-center gap-3 px-4 py-3 border-b">
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, VNI, серверу…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} туннелей</span>
</div>
{/* header */}
<div className="grid grid-cols-[10px_1fr_1fr_auto_auto_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20">
<span />
<span>Имя / VTEP IP</span>
<span>Сервер</span>
<span>VNI</span>
<span>Port</span>
<span>Remote</span>
<span />
<span />
<span>Статус</span>
<span />
</div>
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<NetworkIcon className="size-10 mb-3 opacity-20" />
<p className="text-sm font-medium">VXLAN туннели не найдены</p>
</div>
) : (
filtered.map((t) => (
<TunnelRow key={t.id} tunnel={t} onExport={() => setExportTunnel(t)} />
))
)}
</Card>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, VNI, серверу…"
countLabel={`${filtered.length} туннелей`}
/>
<VxlanDataGrid
tunnels={filtered}
servers={servers}
onExport={setExportTunnel}
/>
</DataPageCard>
{/* Reference */}
<Card>
+24 -238
View File
@@ -3,34 +3,28 @@
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { servers } from "@/lib/data"
import type { WireGuardInterface, WireGuardPeer } from "@/lib/data"
import { Flag } from "@/components/flag"
import { Card, CardContent } from "@/components/ui/card"
import type { WireGuardInterface } from "@/lib/data"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import {
WireguardDataGrid,
type WgIfaceWithServer,
} from "@/components/data-grids/wireguard-data-grid"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { cn } from "@/lib/utils"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"
import {
ShieldCheckIcon, PlusIcon, SearchIcon, KeyRoundIcon,
ChevronDownIcon, ChevronRightIcon, MoreHorizontalIcon,
PencilIcon, Trash2Icon, PowerIcon, CopyIcon, CheckIcon,
CodeXmlIcon, UsersIcon, ActivityIcon, ArrowDownIcon, ArrowUpIcon,
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
CodeXmlIcon, UsersIcon, ActivityIcon,
CopyIcon, CheckIcon,
} from "lucide-react"
// ─── collect all WireGuard interfaces from all servers ────────────────────────
interface WgIfaceWithServer extends WireGuardInterface {
serverId: string
serverName: string
serverCountry: string
}
function collectInterfaces(): WgIfaceWithServer[] {
const result: WgIfaceWithServer[] = []
for (const srv of servers) {
@@ -48,19 +42,6 @@ function collectInterfaces(): WgIfaceWithServer[] {
// ─── helpers ──────────────────────────────────────────────────────────────────
function fmtBytes(n: number | undefined): string {
if (!n) return "—"
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)} ГБ`
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
if (n >= 1_000) return `${(n / 1_000).toFixed(0)} КБ`
return `${n} Б`
}
function truncKey(key: string): string {
if (key.length <= 20) return key
return `${key.slice(0, 8)}${key.slice(-8)}`
}
// ─── RSC generator ────────────────────────────────────────────────────────────
function generateWgRsc(iface: WgIfaceWithServer): string {
@@ -89,161 +70,6 @@ function generateWgRsc(iface: WgIfaceWithServer): string {
return lines.join("\n")
}
// ─── Peer row ─────────────────────────────────────────────────────────────────
function PeerRow({ peer }: { peer: WireGuardPeer }) {
return (
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-4 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20">
{/* public key */}
<div className="flex items-center gap-1.5 min-w-0">
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
{truncKey(peer.publicKey)}
</span>
</div>
{/* allowed IPs */}
<div className="font-mono text-muted-foreground truncate">
{peer.allowedIps.join(", ")}
</div>
{/* handshake */}
<span className={cn(
"font-mono text-[11px] whitespace-nowrap",
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
)}>
{peer.latestHandshake ?? "нет рукопожатия"}
</span>
{/* rx / tx */}
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
<span className="flex items-center gap-0.5">
<ArrowDownIcon className="size-3 text-emerald-500" />{fmtBytes(peer.transferRx)}
</span>
<span className="flex items-center gap-0.5">
<ArrowUpIcon className="size-3 text-blue-400" />{fmtBytes(peer.transferTx)}
</span>
</div>
{/* endpoint */}
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
</div>
)
}
// ─── Interface card ───────────────────────────────────────────────────────────
function IfaceRow({
iface,
expanded,
onToggleExpand,
onExport,
}: {
iface: WgIfaceWithServer
expanded: boolean
onToggleExpand: () => void
onExport: () => void
}) {
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
return (
<div className={cn("border-b last:border-b-0", !iface.enabled && "opacity-50")}>
<div
className="grid grid-cols-[20px_1fr_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-3 items-center hover:bg-muted/30 transition-colors cursor-pointer"
onClick={onToggleExpand}
>
{/* expand */}
<button className="text-muted-foreground" onClick={(e) => { e.stopPropagation(); onToggleExpand() }}>
{expanded
? <ChevronDownIcon className="size-3.5" />
: <ChevronRightIcon className="size-3.5" />}
</button>
{/* name + server */}
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={cn(
"size-2 rounded-full shrink-0",
iface.status === "up" ? "bg-emerald-500 animate-pulse" : "bg-red-500",
)} />
<span className="font-mono font-semibold text-sm">{iface.name}</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
<Flag code={iface.serverCountry} size={12} />
{iface.serverName}
</div>
</div>
{/* port */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Порт</p>
<p className="font-mono text-sm">{iface.listenPort}</p>
</div>
{/* MTU */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">MTU</p>
<p className="font-mono text-sm">{iface.mtu}</p>
</div>
{/* peers */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Пиров</p>
<p className="font-mono text-sm">
<span className="text-emerald-600 dark:text-emerald-400">{onlinePeers}</span>
<span className="text-muted-foreground">/{iface.peers.length}</span>
</p>
</div>
{/* status badge */}
<span className={cn(
"text-[11px] font-mono px-2 py-0.5 rounded border",
iface.status === "up"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-red-500/10 text-red-500 border-red-500/20",
)}>
{iface.status === "up" ? "UP" : "DOWN"}
</span>
{/* menu */}
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7" onClick={(e) => e.stopPropagation()}>
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onExport() }}>
<CodeXmlIcon className="size-4" />Экспорт .rsc
</DropdownMenuItem>
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuItem><PlusIcon className="size-4" />Добавить пира</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem><PowerIcon className="size-4" />{iface.enabled ? "Отключить" : "Включить"}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* expanded peers */}
{expanded && iface.peers.length > 0 && (
<div>
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-4 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground border-t border-border/50">
<span>Public Key</span>
<span>Allowed IPs</span>
<span>Последнее рукопожатие</span>
<span>RX / TX</span>
<span>Endpoint</span>
</div>
{iface.peers.map((p) => <PeerRow key={p.publicKey} peer={p} />)}
</div>
)}
{expanded && iface.peers.length === 0 && (
<div className="px-4 py-4 text-xs text-muted-foreground text-center border-t border-border/50">
Нет пиров
</div>
)}
</div>
)
}
// ─── Export Sheet ─────────────────────────────────────────────────────────────
function ExportSheet({ open, iface, onClose }: {
@@ -309,8 +135,7 @@ function ExportSheet({ open, iface, onClose }: {
export default function WireGuardPage() {
const allIfaces = useMemo(() => collectInterfaces(), [])
const [search, setSearch] = useState("")
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState("")
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
const filtered = useMemo(() => {
@@ -327,14 +152,6 @@ export default function WireGuardPage() {
const onlinePeers = allIfaces.reduce((s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length, 0)
const upIfaces = allIfaces.filter((i) => i.status === "up").length
function toggleExpand(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
return (
<div className="flex flex-col h-full">
<PageHeader
@@ -384,49 +201,18 @@ export default function WireGuardPage() {
</div>
{/* Search + table */}
<Card>
<div className="flex items-center gap-3 px-4 py-3 border-b">
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[260px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, серверу, IP…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} интерфейсов</span>
</div>
{/* table header */}
<div className="grid grid-cols-[20px_1fr_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20">
<span />
<span>Интерфейс / Сервер</span>
<span>Порт</span>
<span>MTU</span>
<span>Пиры</span>
<span>Статус</span>
<span />
</div>
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<ShieldCheckIcon className="size-10 mb-3 opacity-20" />
<p className="text-sm font-medium">Нет WireGuard интерфейсов</p>
<p className="text-xs mt-1">Добавьте первый интерфейс или проверьте поиск</p>
</div>
) : (
filtered.map((iface) => (
<IfaceRow
key={iface.id}
iface={iface}
expanded={expandedIds.has(iface.id)}
onToggleExpand={() => toggleExpand(iface.id)}
onExport={() => setExportIface(iface)}
/>
))
)}
</Card>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, серверу, IP…"
countLabel={`${filtered.length} интерфейсов`}
/>
<WireguardDataGrid
interfaces={filtered}
onExport={setExportIface}
/>
</DataPageCard>
{/* RouterOS reference */}
<Card>
+13
View File
@@ -0,0 +1,13 @@
import type { NextRequest } from "next/server"
import { proxyBackendRequest } from "@/lib/proxy-backend-request"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export const maxDuration = 600
export async function GET(request: NextRequest) {
return proxyBackendRequest(request, "/api/system/database/backup", {
method: "GET",
forwardRequestBody: false,
})
}
+10
View File
@@ -0,0 +1,10 @@
import type { NextRequest } from "next/server"
import { proxyBackendRequest } from "@/lib/proxy-backend-request"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export const maxDuration = 600
export async function POST(request: NextRequest) {
return proxyBackendRequest(request, "/api/system/database/restore", { method: "POST" })
}
+27
View File
@@ -39,6 +39,15 @@
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-destructive-foreground: var(--destructive-foreground);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-invert: var(--invert);
--color-invert-foreground: var(--invert-foreground);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
@@ -74,6 +83,15 @@
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: var(--color-red-800);
--info: var(--color-violet-500);
--info-foreground: var(--color-violet-900);
--success: var(--color-emerald-500);
--success-foreground: var(--color-emerald-900);
--warning: var(--color-yellow-500);
--warning-foreground: var(--color-yellow-900);
--invert: var(--color-zinc-900);
--invert-foreground: var(--color-zinc-50);
/* Borders + inputs */
--border: oklch(0.904 0.006 264.0);
@@ -148,6 +166,15 @@
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: var(--color-red-600);
--info: var(--color-violet-500);
--info-foreground: var(--color-violet-600);
--success: var(--color-emerald-500);
--success-foreground: var(--color-emerald-600);
--warning: var(--color-yellow-500);
--warning-foreground: var(--color-yellow-600);
--invert: var(--color-zinc-700);
--invert-foreground: var(--color-zinc-50);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
+2
View File
@@ -27,6 +27,8 @@ import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
// ── app factory ────────────────────────────────────────────────────────────────
const app = Fastify({
bodyLimit: 512 * 1024 * 1024,
requestTimeout: 10 * 60 * 1000,
logger: {
transport: {
target: "pino-pretty",
+3 -1
View File
@@ -21,5 +21,7 @@
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
"registries": {
"@reui": "https://reui.io/r/{style}/{name}.json"
}
}
+146
View File
@@ -0,0 +1,146 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { Asn } from "@/lib/data"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { FilterIcon, NetworkIcon } from "lucide-react"
interface AsnsDataGridProps {
asns: Asn[]
isLoading?: boolean
pagination?: boolean
}
function AsnsDataGrid({ asns, isLoading, pagination = false }: AsnsDataGridProps) {
const columns = useMemo<ColumnDef<Asn>[]>(
() => [
{
id: "asn",
accessorKey: "asn",
header: ({ column }) => <DataGridSortHeader column={column} title="ASN" className="ml-1" />,
cell: ({ row }) => <span className="font-mono font-semibold">{row.original.asn}</span>,
meta: {
headerTitle: "ASN",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "org",
accessorKey: "org",
header: ({ column }) => <DataGridSortHeader column={column} title="Имя / организация" />,
cell: ({ row }) => (
<span className="font-medium max-w-[min(28rem,50vw)] truncate block" title={row.original.org}>
{row.original.org}
</span>
),
meta: {
headerTitle: "Имя / организация",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "prefixes",
accessorKey: "prefixes",
header: ({ column }) => <DataGridSortHeader column={column} title="Префиксов" />,
cell: ({ row }) => (
<span className="font-mono tabular-nums">{row.original.prefixes.toLocaleString("ru")}</span>
),
meta: {
headerTitle: "Префиксов",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "filter",
accessorKey: "filter",
header: ({ column }) => <DataGridSortHeader column={column} title="Фильтр" />,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
<FilterIcon className="size-3 text-muted-foreground" />
{row.original.filter}
</span>
),
meta: {
headerTitle: "Фильтр",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "updated",
accessorKey: "updated",
header: ({ column }) => <DataGridSortHeader column={column} title="Обновлён" />,
cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.original.updated}</span>,
meta: {
headerTitle: "Обновлён",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "enabled",
accessorKey: "enabled",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => (
<span
className={`text-xs font-medium ${row.original.enabled ? "text-emerald-600" : "text-muted-foreground"}`}
>
{row.original.enabled ? "Активен" : "Отключён"}
</span>
),
meta: {
headerTitle: "Статус",
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[],
)
const table = useReactTable({
data: asns,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
...(pagination ? { getPaginationRowModel: getPaginationRowModel() } : {}),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={asns.length}
isLoading={isLoading}
loadingMode="skeleton"
pagination={pagination}
emptyMessage={
<EmptyState
icon={<NetworkIcon className="size-4" />}
title="Нет ASN"
description="Добавьте автономные системы или импортируйте каталог"
className="border-0 py-12"
/>
}
/>
)
}
export { AsnsDataGrid, type AsnsDataGridProps }
+200
View File
@@ -0,0 +1,200 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { Backup } from "@/lib/data"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { DownloadIcon, HardDriveIcon, RefreshCwIcon, Trash2Icon } from "lucide-react"
interface BackupsDataGridProps {
backups: Backup[]
onDownload: (id: string, filename: string) => void
onRestore: (backup: Backup) => void
onDelete: (id: string) => void
}
function BackupsDataGrid({
backups,
onDownload,
onRestore,
onDelete,
}: BackupsDataGridProps) {
const columns = useMemo<ColumnDef<Backup>[]>(
() => [
{
id: "filename",
accessorKey: "filename",
header: ({ column }) => <DataGridSortHeader column={column} title="Файл" />,
cell: ({ row }) => (
<span className="font-mono text-xs font-medium">{row.original.filename}</span>
),
meta: {
headerTitle: "Файл",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "server",
accessorKey: "server",
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">{row.original.server}</span>
),
meta: {
headerTitle: "Сервер",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "size",
accessorKey: "size",
header: ({ column }) => <DataGridSortHeader column={column} title="Размер" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.size}</span>
),
meta: {
headerTitle: "Размер",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "kind",
accessorKey: "kind",
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
cell: ({ row }) => {
const kind = row.original.kind
return (
<span
className={cn(
"text-xs px-2 py-0.5 rounded border font-medium",
kind === "manual"
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
: "bg-muted text-muted-foreground border-border",
)}
>
{kind === "auto" ? "авто" : "вручную"}
</span>
)
},
meta: {
headerTitle: "Тип",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "notes",
accessorKey: "notes",
header: ({ column }) => <DataGridSortHeader column={column} title="Заметки" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground max-w-[200px] truncate block">
{row.original.notes || "—"}
</span>
),
meta: {
headerTitle: "Заметки",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "created",
accessorKey: "created",
header: ({ column }) => <DataGridSortHeader column={column} title="Создан" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">{row.original.created}</span>
),
meta: {
headerTitle: "Создан",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const b = row.original
return (
<div className="flex items-center gap-1 justify-end opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
<Button
variant="ghost"
size="icon"
className="size-7"
title="Скачать"
onClick={() => onDownload(b.id, b.filename)}
>
<DownloadIcon className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7"
title="Восстановить"
onClick={() => onRestore(b)}
>
<RefreshCwIcon className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-destructive hover:text-destructive"
title="Удалить"
onClick={() => onDelete(b.id)}
>
<Trash2Icon className="size-3.5" />
</Button>
</div>
)
},
enableSorting: false,
size: 120,
meta: {
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[onDelete, onDownload, onRestore],
)
const table = useReactTable({
data: backups,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (backups.length === 0) {
return (
<EmptyState
icon={<HardDriveIcon className="size-4" />}
title="Нет бэкапов"
description="Создайте первый бэкап вручную или настройте расписание"
className="border-0 py-10"
/>
)
}
return <DataGridShell table={table} recordCount={backups.length} />
}
export { BackupsDataGrid, type BackupsDataGridProps }
@@ -0,0 +1,130 @@
"use client"
import { useState } from "react"
import { cn } from "@/lib/utils"
import { ClipboardCopyIcon } from "lucide-react"
import type { BgpSessionRow } from "@/lib/bgp/types"
import { fmtBgpNum, rscBgpSnippet } from "@/lib/bgp/helpers"
function CapChip({ cap }: { cap: string }) {
return (
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border bg-muted/60 text-muted-foreground border-border/60">
{cap}
</span>
)
}
function PrefixBar({ rx, tx, active }: { rx: number; tx: number; active: number }) {
const max = Math.max(rx, 1)
return (
<div className="flex flex-col gap-1.5 text-[10px] font-mono">
{[
{ label: "Получено", val: rx, color: "bg-[var(--chart-rx)]", w: rx / max },
{ label: "Активных", val: active, color: "bg-[var(--chart-1)]", w: active / max },
{ label: "Отправлено", val: tx, color: "bg-[var(--chart-tx)]", w: Math.min(tx / max, 1) },
].map((r) => (
<div key={r.label} className="flex items-center gap-2">
<span className="w-20 text-muted-foreground shrink-0">{r.label}</span>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className={cn("h-full rounded-full", r.color)}
style={{ width: `${Math.max(r.w * 100, r.val > 0 ? 2 : 0)}%` }}
/>
</div>
<span className="w-14 text-right tabular-nums">{fmtBgpNum(r.val)}</span>
</div>
))}
</div>
)
}
function BgpSessionDetail({ session }: { session: BgpSessionRow }) {
const [copied, setCopied] = useState(false)
function copy() {
navigator.clipboard.writeText(rscBgpSnippet(session)).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1800)
})
}
return (
<div className="px-4 pb-4 pt-2 bg-muted/20 border-t border-border/60">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
{[
{ label: "Router ID", value: session.routerId },
{ label: "Hold / KA", value: `${session.holdTime}s / ${session.keepalive}s` },
{ label: "AFI/SAFI", value: session.afi },
{
label: "Сообщения ↓/↑",
value: `${fmtBgpNum(session.inputMessages)} / ${fmtBgpNum(session.outputMessages)}`,
},
].map(({ label, value }) => (
<div key={label}>
<p className="text-[10px] text-muted-foreground mb-0.5">{label}</p>
<p className="text-xs font-mono font-medium">{value}</p>
</div>
))}
</div>
{session.state === "Established" && (
<div className="mb-4">
<p className="text-[10px] text-muted-foreground mb-2 uppercase tracking-wider font-semibold">
Префиксы
</p>
<PrefixBar
rx={session.prefixesRx}
tx={session.prefixesTx}
active={session.prefixesActive}
/>
</div>
)}
{session.capabilities.length > 0 && (
<div className="mb-4">
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">
Capabilities
</p>
<div className="flex flex-wrap gap-1.5">
{session.capabilities.map((c) => (
<CapChip key={c} cap={c} />
))}
</div>
</div>
)}
{session.lastError && (
<div className="mb-4 flex items-center gap-2 rounded-md border border-red-500/20 bg-red-500/5 px-3 py-2">
<span className="size-1.5 rounded-full bg-red-500 shrink-0" />
<p className="text-xs font-mono text-red-500">{session.lastError}</p>
</div>
)}
<div className="mt-2">
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">
RouterOS Export
</p>
<div className="rounded-md bg-[#0a0f1a] border border-white/8 px-3 py-2.5 flex items-start justify-between gap-3">
<pre className="text-[10px] font-mono text-[#94a3b8] leading-relaxed whitespace-pre-wrap flex-1 min-w-0">
{rscBgpSnippet(session)}
</pre>
<button
type="button"
onClick={copy}
className={cn(
"shrink-0 flex items-center gap-1 text-[10px] px-2 py-1 rounded border transition-colors",
copied
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-400"
: "border-white/10 text-white/40 hover:text-white/70 hover:border-white/20",
)}
>
<ClipboardCopyIcon className="size-3" />
{copied ? "Скопировано" : "Копировать"}
</button>
</div>
</div>
</div>
)
}
export { BgpSessionDetail }
@@ -0,0 +1,293 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { BgpSessionDetail } from "@/components/data-grids/bgp-session-detail"
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
import { BGP_AS_NAMES } from "@/lib/bgp/types"
import { fmtBgpNum } from "@/lib/bgp/helpers"
import { ChevronDownIcon, ChevronRightIcon, NetworkIcon } from "lucide-react"
const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; label: string }> = {
Established: {
bg: "bg-emerald-500/10",
text: "text-emerald-600 dark:text-emerald-400",
dot: "bg-emerald-500",
label: "Established",
},
Active: {
bg: "bg-amber-500/10",
text: "text-amber-600 dark:text-amber-400",
dot: "bg-amber-500",
label: "Active",
},
Idle: {
bg: "bg-slate-500/10",
text: "text-slate-500 dark:text-slate-400",
dot: "bg-slate-500",
label: "Idle",
},
Connect: {
bg: "bg-blue-500/10",
text: "text-blue-600 dark:text-blue-400",
dot: "bg-blue-500",
label: "Connect",
},
OpenSent: {
bg: "bg-violet-500/10",
text: "text-violet-600 dark:text-violet-400",
dot: "bg-violet-500",
label: "OpenSent",
},
OpenConfirm: {
bg: "bg-violet-500/10",
text: "text-violet-600 dark:text-violet-400",
dot: "bg-violet-500",
label: "OpenConfirm",
},
}
const TYPE_STYLE: Record<BgpType, { bg: string; text: string }> = {
eBGP: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" },
iBGP: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
}
function StateBadge({ state }: { state: BgpState }) {
const s = STATE_STYLE[state]
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded border px-2 py-0.5 text-[11px] font-semibold",
s.bg,
s.text,
"border-current/20",
)}
>
<span className={cn("size-1.5 rounded-full shrink-0", s.dot)} />
{s.label}
</span>
)
}
function TypeBadge({ type }: { type: BgpType }) {
const s = TYPE_STYLE[type]
return (
<span
className={cn(
"inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold",
s.bg,
s.text,
"border-current/20",
)}
>
{type}
</span>
)
}
interface BgpSessionsDataGridProps {
sessions: BgpSessionRow[]
}
function BgpSessionsDataGrid({ sessions }: BgpSessionsDataGridProps) {
const columns = useMemo<ColumnDef<BgpSessionRow>[]>(
() => [
{
id: "serverLabel",
accessorKey: "serverLabel",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Роутер" className="ml-1" />
),
cell: ({ row }) => {
const expanded = row.getIsExpanded()
return (
<div className="flex items-center gap-2">
{expanded ? (
<ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground/40" />
)}
<span className="font-mono whitespace-nowrap">{row.original.serverLabel}</span>
</div>
)
},
meta: {
headerTitle: "Роутер",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (row: BgpSessionRow) => <BgpSessionDetail session={row} />,
},
},
{
id: "peerIp",
accessorKey: "peerIp",
header: ({ column }) => <DataGridSortHeader column={column} title="Peer IP" />,
cell: ({ row }) => <span className="font-mono">{row.original.peerIp}</span>,
meta: {
headerTitle: "Peer IP",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "remoteAs",
accessorKey: "remoteAs",
header: ({ column }) => <DataGridSortHeader column={column} title="Remote AS" />,
cell: ({ row }) => {
const as = row.original.remoteAs
return (
<div className="flex items-center gap-1.5 font-mono">
<span>AS{as}</span>
{BGP_AS_NAMES[as] && (
<span className="text-muted-foreground text-[10px]">{BGP_AS_NAMES[as]}</span>
)}
</div>
)
},
meta: {
headerTitle: "Remote AS",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "description",
accessorKey: "description",
header: ({ column }) => <DataGridSortHeader column={column} title="Описание" />,
cell: ({ row }) => (
<span className="text-muted-foreground max-w-[180px] truncate block">
{row.original.description}
</span>
),
meta: {
headerTitle: "Описание",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "type",
accessorKey: "type",
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
cell: ({ row }) => <TypeBadge type={row.original.type} />,
meta: {
headerTitle: "Тип",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "state",
accessorKey: "state",
header: ({ column }) => <DataGridSortHeader column={column} title="Состояние" />,
cell: ({ row }) => <StateBadge state={row.original.state} />,
meta: {
headerTitle: "Состояние",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "uptime",
accessorKey: "uptime",
header: ({ column }) => <DataGridSortHeader column={column} title="Uptime" />,
cell: ({ row }) => (
<span className="font-mono tabular-nums text-muted-foreground">
{row.original.uptime ?? "—"}
</span>
),
meta: {
headerTitle: "Uptime",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "prefixesRx",
accessorKey: "prefixesRx",
header: ({ column }) => <DataGridSortHeader column={column} title="Prefixes ↓" />,
cell: ({ row }) => {
const n = row.original.prefixesRx
return n > 0 ? (
<span className="font-mono tabular-nums text-emerald-600 dark:text-emerald-400 text-right block">
{fmtBgpNum(n)}
</span>
) : (
<span className="text-muted-foreground"></span>
)
},
meta: {
headerTitle: "Prefixes ↓",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "prefixesTx",
accessorKey: "prefixesTx",
header: ({ column }) => <DataGridSortHeader column={column} title="Prefixes ↑" />,
cell: ({ row }) => {
const n = row.original.prefixesTx
return n > 0 ? (
<span className="font-mono tabular-nums text-[var(--chart-tx)] text-right block">
{fmtBgpNum(n)}
</span>
) : (
<span className="text-muted-foreground"></span>
)
},
meta: {
headerTitle: "Prefixes ↑",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
],
[],
)
const table = useReactTable({
data: sessions,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowId: (row) => row.id,
getRowCanExpand: () => true,
})
if (sessions.length === 0) {
return (
<EmptyState
icon={<NetworkIcon className="size-4" />}
title="Нет BGP-сессий"
description="Измените фильтры или проверьте подключение к роутерам"
className="border-0 py-10"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={sessions.length}
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
/>
)
}
export { BgpSessionsDataGrid, type BgpSessionsDataGridProps }
@@ -0,0 +1,61 @@
"use client"
import type { CertificateDto } from "@mmapp/contracts/certificates"
import { cn } from "@/lib/utils"
function daysLeftBar(days: number, total = 365): number {
if (days <= 0) return 0
return Math.min(100, Math.round((days / total) * 100))
}
function CertificateExpandedDetail({ cert }: { cert: CertificateDto }) {
const pct = daysLeftBar(cert.daysLeft)
return (
<div className="px-10 pb-4 grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs border-t border-border/50 pt-3">
<div>
<p className="text-muted-foreground mb-1">Key size</p>
<p className="font-mono font-medium">{cert.keySize} bit</p>
</div>
<div>
<p className="text-muted-foreground mb-1">Действителен с</p>
<p className="font-mono">{cert.validFrom}</p>
</div>
<div>
<p className="text-muted-foreground mb-1">SAN / Alt Names</p>
<div className="flex flex-wrap gap-1">
{cert.sans.length > 0
? cert.sans.map((s) => (
<span key={s} className="font-mono bg-muted px-1.5 py-0.5 rounded">
{s}
</span>
))
: <span className="text-muted-foreground"></span>}
</div>
</div>
<div>
<p className="text-muted-foreground mb-1">Trusted</p>
<p className={cert.trusted ? "text-emerald-600 dark:text-emerald-400" : "text-red-500"}>
{cert.trusted ? "Да (доверенный)" : "Нет (не доверенный)"}
</p>
</div>
<div className="col-span-2 sm:col-span-4">
<div className="h-1.5 bg-muted rounded-full overflow-hidden max-w-xs">
<div
className={cn(
"h-full rounded-full",
cert.daysLeft < 0 || cert.daysLeft <= 7
? "bg-red-500"
: cert.daysLeft <= 30
? "bg-amber-500"
: "bg-emerald-500",
)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
</div>
)
}
export { CertificateExpandedDetail, daysLeftBar }
@@ -0,0 +1,279 @@
"use client"
import { useMemo, type ReactNode } from "react"
import {
type ColumnDef,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { CertStatus, Server } from "@/lib/data"
import type { CertificateDto } from "@mmapp/contracts/certificates"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import {
CertificateExpandedDetail,
daysLeftBar,
} from "@/components/data-grids/certificate-expanded-detail"
import { EmptyState } from "@/components/empty-state"
import {
BadgeCheckIcon,
ChevronDownIcon,
ChevronRightIcon,
ServerIcon,
ShieldAlertIcon,
ShieldCheckIcon,
ShieldOffIcon,
} from "lucide-react"
const STATUS_CONFIG: Record<
CertStatus,
{ label: string; icon: ReactNode; badge: string; row: string }
> = {
valid: {
label: "Действителен",
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />,
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
row: "",
},
expired: {
label: "Истёк",
icon: <ShieldOffIcon className="size-4 text-red-500" />,
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
row: "bg-red-500/5",
},
revoked: {
label: "Отозван",
icon: <ShieldAlertIcon className="size-4 text-amber-500" />,
badge: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
row: "bg-amber-500/5",
},
}
function daysLeftColor(days: number): string {
if (days < 0) return "text-red-500"
if (days <= 7) return "text-red-500"
if (days <= 30) return "text-amber-500"
return "text-emerald-600 dark:text-emerald-400"
}
function CertDaysCell({ cert }: { cert: CertificateDto }) {
const pct = daysLeftBar(cert.daysLeft)
return (
<>
<div className="flex items-center justify-between text-xs mb-1">
<span className={cn("font-mono font-medium", daysLeftColor(cert.daysLeft))}>
{cert.daysLeft < 0 ? `Истёк ${-cert.daysLeft}д назад` : `${cert.daysLeft}д осталось`}
</span>
<span className="text-muted-foreground text-[10px]">{cert.validUntil}</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full",
cert.daysLeft < 0 || cert.daysLeft <= 7
? "bg-red-500"
: cert.daysLeft <= 30
? "bg-amber-500"
: "bg-emerald-500",
)}
style={{ width: `${pct}%` }}
/>
</div>
</>
)
}
interface CertificatesDataGridProps {
certificates: CertificateDto[]
serverMap: Map<string, Server>
isLoading?: boolean
}
function CertificatesDataGrid({ certificates, serverMap, isLoading }: CertificatesDataGridProps) {
const columns = useMemo<ColumnDef<CertificateDto>[]>(
() => [
{
id: "name",
accessorKey: "name",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Сертификат" className="ml-1" />
),
cell: ({ row }) => {
const cert = row.original
const cfg = STATUS_CONFIG[cert.status]
const expanded = row.getIsExpanded()
return (
<div className="flex items-start gap-2 min-w-0">
{expanded ? (
<ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
)}
<div className="min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="shrink-0">{cfg.icon}</span>
<span className="font-medium text-sm truncate" title={cert.name}>
{cert.name}
</span>
</div>
<p className="text-xs text-muted-foreground font-mono mt-0.5 truncate" title={cert.commonName}>
{cert.commonName}
</p>
</div>
</div>
)
},
meta: {
headerTitle: "Сертификат",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (row: CertificateDto) => <CertificateExpandedDetail cert={row} />,
},
},
{
id: "server",
accessorKey: "serverId",
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
cell: ({ row }) => {
const cert = row.original
const server = serverMap.get(cert.serverId)
return server ? (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
<Flag code={server.country} size={12} />
<span className="font-mono truncate">{server.name}</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
<ServerIcon className="size-3.5" />
<span className="font-mono truncate">{cert.serverName ?? cert.serverId}</span>
</div>
)
},
meta: {
headerTitle: "Сервер",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "issuedBy",
accessorKey: "issuedBy",
header: ({ column }) => <DataGridSortHeader column={column} title="Издатель" />,
cell: ({ row }) => (
<p className="text-xs text-muted-foreground truncate min-w-0" title={row.original.issuedBy}>
{row.original.issuedBy}
</p>
),
meta: {
headerTitle: "Издатель",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "daysLeft",
accessorKey: "daysLeft",
header: ({ column }) => <DataGridSortHeader column={column} title="Срок" />,
cell: ({ row }) => (
<div className="min-w-0">
<CertDaysCell cert={row.original} />
</div>
),
meta: {
headerTitle: "Срок",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "usage",
accessorKey: "usage",
header: () => <span className="text-xs font-medium text-muted-foreground">Использование</span>,
enableSorting: false,
cell: ({ row }) => (
<div className="flex flex-wrap gap-1 min-w-0 overflow-hidden">
{row.original.usage.map((u) => (
<span
key={u}
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground border"
>
{u}
</span>
))}
</div>
),
meta: {
headerTitle: "Использование",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "status",
accessorKey: "status",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => {
const cfg = STATUS_CONFIG[row.original.status]
return (
<span
className={cn(
"text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap",
cfg.badge,
)}
>
{cfg.label}
</span>
)
},
meta: {
headerTitle: "Статус",
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[serverMap],
)
const table = useReactTable({
data: certificates,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowId: (row) => row.id,
getRowCanExpand: () => true,
})
if (!isLoading && certificates.length === 0) {
return (
<EmptyState
icon={<ShieldCheckIcon className="size-4" />}
title="Нет сертификатов"
description="Выпустите или импортируйте сертификат"
className="border-0 py-12"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={certificates.length}
isLoading={isLoading}
loadingMode="skeleton"
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
/>
)
}
export { CertificatesDataGrid, STATUS_CONFIG, type CertificatesDataGridProps }
@@ -0,0 +1,272 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import {
CheckIcon,
ChevronRightIcon,
CopyIcon,
ServerIcon,
TagIcon,
} from "lucide-react"
export type CommunityType = "standard" | "no-export" | "no-advertise" | "local-as" | "custom"
export interface CommunityRow {
id: string
value: string
name: string
description: string
type: CommunityType
filterIds: string[]
serverCount: number
prefixCount: number
action: "permit" | "deny" | "local-pref" | "metric"
actionValue?: number
enabled: boolean
}
const TYPE_LABELS: Record<CommunityType, string> = {
standard: "Стандартный",
"no-export": "No-export",
"no-advertise": "No-advertise",
"local-as": "Local-AS",
custom: "Кастомный",
}
const ACTION_LABELS: Record<CommunityRow["action"], string> = {
permit: "Permit",
deny: "Deny",
"local-pref": "Local-pref",
metric: "MED/Metric",
}
const ACTION_COLOR: Record<CommunityRow["action"], string> = {
permit: "text-emerald-500",
deny: "text-red-500",
"local-pref": "text-blue-500",
metric: "text-amber-500",
}
interface CommunitiesDataGridProps {
communities: CommunityRow[]
selectedId?: string | null
copiedValue?: string | null
onSelect: (community: CommunityRow) => void
onCopy: (value: string) => void
}
function CommunitiesDataGrid({
communities,
selectedId,
copiedValue,
onSelect,
onCopy,
}: CommunitiesDataGridProps) {
const columns = useMemo<ColumnDef<CommunityRow>[]>(
() => [
{
id: "value",
accessorKey: "value",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Community" className="ml-1" />
),
cell: ({ row }) => {
const c = row.original
return (
<div
className="flex items-center gap-2"
data-community-disabled={!c.enabled ? true : undefined}
>
<TagIcon className="size-3.5 text-muted-foreground shrink-0" />
<span className="font-mono text-xs font-medium bg-muted px-1.5 py-0.5 rounded">
{c.value}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onCopy(c.value)
}}
className="text-muted-foreground/40 hover:text-muted-foreground transition-colors"
>
{copiedValue === c.value ? (
<CheckIcon className="size-3" />
) : (
<CopyIcon className="size-3" />
)}
</button>
</div>
)
},
meta: {
headerTitle: "Community",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "name",
accessorKey: "name",
header: ({ column }) => <DataGridSortHeader column={column} title="Имя / описание" />,
cell: ({ row }) => (
<div className="min-w-0">
<p className="font-medium text-xs">{row.original.name}</p>
<p className="text-xs text-muted-foreground line-clamp-1">{row.original.description}</p>
</div>
),
meta: {
headerTitle: "Имя / описание",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "type",
accessorKey: "type",
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">
{TYPE_LABELS[row.original.type]}
</span>
),
meta: {
headerTitle: "Тип",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "action",
accessorKey: "action",
header: ({ column }) => <DataGridSortHeader column={column} title="Действие" />,
cell: ({ row }) => {
const c = row.original
return (
<span className={cn("text-xs font-medium", ACTION_COLOR[c.action])}>
{ACTION_LABELS[c.action]}
{c.actionValue !== undefined ? ` ${c.actionValue}` : ""}
</span>
)
},
meta: {
headerTitle: "Действие",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "prefixCount",
accessorKey: "prefixCount",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Маршрутов" className="ml-auto" />
),
cell: ({ row }) => (
<span className="font-mono text-xs tabular-nums text-right block">
{row.original.prefixCount.toLocaleString("ru-RU")}
</span>
),
meta: {
headerTitle: "Маршрутов",
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
},
},
{
id: "serverCount",
accessorKey: "serverCount",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Серверов" className="ml-auto" />
),
cell: ({ row }) => (
<div className="flex items-center justify-end gap-1">
<ServerIcon className="size-3 text-muted-foreground" />
<span className="font-mono text-xs tabular-nums">
{row.original.serverCount.toLocaleString("ru-RU")}
</span>
</div>
),
meta: {
headerTitle: "Серверов",
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
},
},
{
id: "chevron",
header: () => <span className="sr-only">Детали</span>,
enableSorting: false,
cell: () => <ChevronRightIcon className="size-4 text-muted-foreground/40" />,
size: 40,
meta: {
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[copiedValue, onCopy],
)
const rowSelection = useMemo(
() => (selectedId ? { [selectedId]: true } : {}),
[selectedId],
)
const table = useReactTable({
data: communities,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
enableRowSelection: true,
state: { rowSelection },
})
if (communities.length === 0) {
return (
<EmptyState
icon={<TagIcon className="size-4" />}
title="Ничего не найдено"
className="border-0 py-16"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={communities.length}
onRowClick={(row) => onSelect(row)}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: cn(
"group/row",
"data-[state=selected]:bg-primary/5",
"[&:has([data-community-disabled=true])]:opacity-50",
),
}}
/>
)
}
export {
CommunitiesDataGrid,
type CommunitiesDataGridProps,
TYPE_LABELS,
ACTION_LABELS,
ACTION_COLOR,
}
+116
View File
@@ -0,0 +1,116 @@
"use client"
import { useMemo, type ReactNode } from "react"
import {
type ColumnDef,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { InboxIcon } from "lucide-react"
export interface CompactDataGridColumn<T> {
id: string
header: string
accessorKey?: keyof T & string
cell?: (row: T) => ReactNode
enableSorting?: boolean
headerClassName?: string
cellClassName?: string
}
interface CompactDataGridProps<T extends { id: string }> {
data: T[]
columns: CompactDataGridColumn<T>[]
isLoading?: boolean
emptyTitle?: string
emptyDescription?: string
onRowClick?: (row: T) => void
getExpandedContent?: (row: T) => ReactNode
compact?: boolean
}
function CompactDataGrid<T extends { id: string }>({
data,
columns,
isLoading,
emptyTitle = "Нет записей",
emptyDescription,
onRowClick,
getExpandedContent,
compact = false,
}: CompactDataGridProps<T>) {
const pad = compact ? "py-2" : DATA_GRID_CELL_PAD
const padFirst = compact ? "pl-4 py-2" : DATA_GRID_CELL_PAD_FIRST
const padLast = compact ? "pr-4 py-2" : DATA_GRID_CELL_PAD_LAST
const columnDefs = useMemo<ColumnDef<T>[]>(
() =>
columns.map((col, index) => ({
id: col.id,
accessorKey: col.accessorKey ?? col.id,
header: ({ column }) => (
<DataGridSortHeader column={column} title={col.header} className={index === 0 ? "ml-1" : undefined} />
),
cell: ({ row }) => (col.cell ? col.cell(row.original) : String(row.getValue(col.id) ?? "—")),
enableSorting: col.enableSorting !== false,
meta: {
headerTitle: col.header,
headerClassName:
col.headerClassName ??
(index === 0 ? padFirst : index === columns.length - 1 ? padLast : pad),
cellClassName:
col.cellClassName ??
(index === 0 ? padFirst : index === columns.length - 1 ? padLast : pad),
...(getExpandedContent && index === 0
? { expandedContent: getExpandedContent }
: {}),
},
})),
[columns, getExpandedContent, pad, padFirst, padLast],
)
const table = useReactTable({
data,
columns: columnDefs,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
...(getExpandedContent ? { getExpandedRowModel: getExpandedRowModel() } : {}),
getRowId: (row) => row.id,
...(getExpandedContent ? { getRowCanExpand: () => true } : {}),
})
return (
<DataGridShell
table={table}
recordCount={data.length}
isLoading={isLoading}
loadingMode="skeleton"
onRowClick={
onRowClick ??
(getExpandedContent
? (row) => table.getRow(row.id).toggleExpanded()
: undefined)
}
emptyMessage={
<EmptyState
icon={<InboxIcon className="size-4" />}
title={emptyTitle}
description={emptyDescription}
className="border-0 py-10"
/>
}
/>
)
}
export { CompactDataGrid, type CompactDataGridProps }
@@ -0,0 +1,191 @@
"use client"
import { useMemo } from "react"
import type { PingProbe, Server, ServerType } from "@/lib/data"
import { Flag } from "@/components/flag"
import { StatusBadge } from "@/components/status-badge"
import { StatusDot } from "@/components/status-dot"
import { Sparkline } from "@/components/sparkline"
import { CompactDataGrid, type CompactDataGridColumn } from "@/components/data-grids/compact-data-grid"
import { cn } from "@/lib/utils"
import { FilterIcon } from "lucide-react"
function formatLossPct(loss: number): string {
if (!Number.isFinite(loss)) return "—"
return Number.isInteger(loss) ? `${loss}%` : `${loss.toFixed(1)}%`
}
function TypeChip({ type }: { type: ServerType }) {
return (
<span
className={cn(
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
type === "home-router"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: type === "jump-host"
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
)}
>
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
</span>
)
}
function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server[] }) {
const srv = catalog.find((s) => s.id === probe.srcServerId)
const iface = (probe.srcInterface ?? "").trim() || "auto"
if (!srv) {
return (
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
<span className="mt-1 shrink-0 inline-flex">
<StatusDot status="offline" />
</span>
<div className="min-w-0">
<p className="text-[13px] font-medium text-muted-foreground truncate">
Сервер <span className="font-mono tabular-nums">{probe.srcServerId}</span>
</p>
<p className="text-[11px] font-mono text-muted-foreground truncate mt-0.5" title={iface}>
{iface}
</p>
</div>
</div>
)
}
return (
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
<span className="mt-1 shrink-0 inline-flex">
<StatusDot status={srv.status} pulse={srv.status === "online"} />
</span>
<div className="flex gap-2 min-w-0 flex-1">
<Flag code={srv.country} size={16} className="shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[13px] font-medium leading-tight truncate">{srv.name}</span>
<TypeChip type={srv.type} />
</div>
<p className="text-[11px] text-muted-foreground mt-0.5 truncate" title={`Интерфейс: ${iface}`}>
<span className="font-mono tabular-nums">{iface}</span>
{srv.site && srv.site !== "—" && (
<span className="text-muted-foreground/90"> · {srv.site}</span>
)}
</p>
</div>
</div>
</div>
)
}
interface DashboardActiveProbesDataGridProps {
probes: PingProbe[]
catalog: Server[]
isLoading?: boolean
emptyTitle?: string
emptyDescription?: string
}
function DashboardActiveProbesDataGrid({
probes,
catalog,
isLoading,
emptyTitle = "Нет активных проб",
emptyDescription,
}: DashboardActiveProbesDataGridProps) {
const columns = useMemo<CompactDataGridColumn<PingProbe>[]>(
() => [
{
id: "source",
header: "Источник",
enableSorting: false,
cell: (p) => <ProbeSourceCell probe={p} catalog={catalog} />,
},
{
id: "name",
header: "Проба",
accessorKey: "name",
cell: (p) => <span className="font-medium">{p.name}</span>,
},
{
id: "target",
header: "Цель",
accessorKey: "target",
cell: (p) => <span className="font-mono text-xs text-muted-foreground">{p.target}</span>,
},
{
id: "filter",
header: "Фильтр",
accessorKey: "filter",
enableSorting: false,
cell: (p) => (
<span className="inline-flex items-center gap-1 text-xs border border-border rounded px-2 py-0.5">
<FilterIcon className="size-3 text-muted-foreground" />
{p.filter}
</span>
),
},
{
id: "rtt",
header: "RTT",
headerClassName: "text-right",
cellClassName: "font-mono text-right",
cell: (p) => (p.rtt == null ? "—" : `${p.rtt} мс`),
},
{
id: "loss",
header: "Потери",
headerClassName: "text-right",
cellClassName: "font-mono text-right",
cell: (p) => (
<span
className={cn(
p.loss > 5 ? "text-red-500" : p.loss > 0 ? "text-amber-500" : "text-muted-foreground",
)}
>
{formatLossPct(p.loss)}
</span>
),
},
{
id: "series",
header: "60с",
enableSorting: false,
headerClassName: "w-36",
cell: (p) => {
const sparkColor =
p.status === "down"
? "hsl(0 84% 60%)"
: p.status === "warn"
? "hsl(32 94% 44%)"
: "hsl(142 76% 36%)"
return <Sparkline data={p.series} width={120} height={24} color={sparkColor} />
},
},
{
id: "status",
header: "Статус",
enableSorting: false,
cell: (p) => (
<StatusBadge
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
/>
),
},
],
[catalog],
)
return (
<CompactDataGrid
data={probes}
columns={columns}
compact
isLoading={isLoading}
emptyTitle={emptyTitle}
emptyDescription={emptyDescription}
/>
)
}
export { DashboardActiveProbesDataGrid, type DashboardActiveProbesDataGridProps }
@@ -0,0 +1,219 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { SchedulerJobStatusDto } from "@/lib/scheduler-settings"
import { FormToggle } from "@/components/form-kit"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { cn } from "@/lib/utils"
import { RefreshCwIcon } from "lucide-react"
export interface SchedulerJobGridRow {
id: string
jobKey: string
label: string
description: string
fixedSchedule: boolean
enabled: boolean
intervalValue: string
intervalReadOnly: boolean
intervalDisabled: boolean
defaultInterval: number
job?: SchedulerJobStatusDto
onEnabledChange?: (enabled: boolean) => void
onIntervalChange: (value: string) => void
onRunNow: () => void
runNowLoading: boolean
saveBusy: boolean
}
interface DataCollectionSchedulerDataGridProps {
rows: SchedulerJobGridRow[]
}
function DataCollectionSchedulerDataGrid({ rows }: DataCollectionSchedulerDataGridProps) {
const columns = useMemo<ColumnDef<SchedulerJobGridRow>[]>(
() => [
{
id: "task",
accessorKey: "label",
header: () => <span className="text-xs font-medium text-muted-foreground">Задача</span>,
enableSorting: false,
cell: ({ row }) => (
<div className="align-top">
<span className="font-medium text-sm">{row.original.label}</span>
<p className="text-[11px] text-muted-foreground mt-0.5 leading-snug">
{row.original.description}
</p>
<p className="text-[11px] text-muted-foreground font-mono mt-1">{row.original.jobKey}</p>
</div>
),
meta: {
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: cn(DATA_GRID_CELL_PAD_FIRST, "align-top"),
},
},
{
id: "enabled",
accessorKey: "enabled",
header: () => (
<span className="text-xs font-medium text-muted-foreground text-center block">Вкл</span>
),
enableSorting: false,
cell: ({ row }) => {
const { fixedSchedule, enabled, saveBusy, onEnabledChange } = row.original
return (
<div className="text-center align-top">
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
<FormToggle
checked={enabled}
disabled={fixedSchedule || saveBusy}
onChange={(v) => {
if (fixedSchedule || saveBusy) return
onEnabledChange?.(v)
}}
/>
</span>
</div>
)
},
size: 56,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: cn(DATA_GRID_CELL_PAD, "align-top") },
},
{
id: "interval",
accessorKey: "intervalValue",
header: () => <span className="text-xs font-medium text-muted-foreground">Интервал (с)</span>,
enableSorting: false,
cell: ({ row }) => {
const r = row.original
return (
<div className="w-28 align-top">
<Input
value={r.intervalValue}
onChange={(e) => r.onIntervalChange(e.target.value)}
className="h-8 text-sm tabular-nums"
inputMode="numeric"
readOnly={r.intervalReadOnly}
disabled={r.intervalDisabled}
placeholder={String(r.defaultInterval)}
/>
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: cn(DATA_GRID_CELL_PAD, "align-top") },
},
{
id: "lastRun",
accessorFn: (row) => row.job?.lastFinishedAt ?? "",
header: () => <span className="text-xs font-medium text-muted-foreground">Последний прогон</span>,
enableSorting: false,
cell: ({ row }) => {
const j = row.original.job
return (
<div className="text-xs text-muted-foreground align-top">
{j?.lastFinishedAt ? new Date(j.lastFinishedAt).toLocaleString("ru-RU") : "—"}
{j?.lastDurationMs != null && (
<span className="block text-[11px]">{j.lastDurationMs} мс</span>
)}
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: cn(DATA_GRID_CELL_PAD, "align-top") },
},
{
id: "status",
accessorFn: (row) => row.job?.lastStatus ?? "",
header: () => <span className="text-xs font-medium text-muted-foreground">Статус</span>,
enableSorting: false,
cell: ({ row }) => {
const j = row.original.job
return (
<div className="flex flex-wrap items-center gap-1.5 align-top">
{j?.running ? (
<Badge variant="secondary" className="text-[10px]">
выполняется
</Badge>
) : null}
{j?.lastStatus ? (
<Badge
variant="outline"
className={cn(
"text-[10px]",
j.lastStatus === "ok" && "border-emerald-500/40 text-emerald-700 dark:text-emerald-400",
j.lastStatus === "error" && "border-destructive/50 text-destructive",
)}
>
{j.lastStatus}
</Badge>
) : null}
{j?.lastError ? (
<span className="text-[10px] text-destructive max-w-[200px] truncate block" title={j.lastError}>
{j.lastError}
</span>
) : null}
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: cn(DATA_GRID_CELL_PAD, "align-top") },
},
{
id: "runNow",
header: () => (
<span className="text-xs font-medium text-muted-foreground text-right block">Сейчас</span>
),
enableSorting: false,
cell: ({ row }) => {
const r = row.original
return (
<div className="text-right align-top">
<Button
size="sm"
variant="outline"
className="h-8"
disabled={r.job?.running || r.runNowLoading}
onClick={r.onRunNow}
>
<RefreshCwIcon className={cn("size-3.5", r.runNowLoading && "animate-spin")} />
</Button>
</div>
)
},
meta: {
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "align-top"),
},
},
],
[],
)
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={rows.length}
tableClassNames={{ bodyRow: "group/row hover:bg-muted/40 text-sm" }}
/>
)
}
export { DataCollectionSchedulerDataGrid, type DataCollectionSchedulerDataGridProps }
+155
View File
@@ -0,0 +1,155 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { Domain } from "@/lib/data"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { FilterIcon, GlobeIcon } from "lucide-react"
interface DomainsDataGridProps {
domains: Domain[]
isLoading?: boolean
pagination?: boolean
}
function DomainsDataGrid({ domains, isLoading, pagination = false }: DomainsDataGridProps) {
const columns = useMemo<ColumnDef<Domain>[]>(
() => [
{
id: "domain",
accessorKey: "domain",
header: ({ column }) => <DataGridSortHeader column={column} title="Домен" className="ml-1" />,
cell: ({ row }) => <span className="font-medium">{row.original.domain}</span>,
meta: {
headerTitle: "Домен",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "resolvedIp",
accessorKey: "resolvedIp",
header: ({ column }) => <DataGridSortHeader column={column} title="Resolved IP" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.resolvedIp}</span>
),
meta: {
headerTitle: "Resolved IP",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "asn",
accessorKey: "asn",
header: ({ column }) => <DataGridSortHeader column={column} title="ASN" />,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.asn}</span>,
meta: {
headerTitle: "ASN",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "purpose",
accessorKey: "purpose",
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
cell: ({ row }) => (
<span className="text-xs border border-border rounded px-2 py-0.5">{row.original.purpose}</span>
),
meta: {
headerTitle: "Назначение",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "filter",
accessorKey: "filter",
header: ({ column }) => <DataGridSortHeader column={column} title="Фильтр" />,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
<FilterIcon className="size-3 text-muted-foreground" />
{row.original.filter}
</span>
),
meta: {
headerTitle: "Фильтр",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "updated",
accessorKey: "updated",
header: ({ column }) => <DataGridSortHeader column={column} title="Обновлён" />,
cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.original.updated}</span>,
meta: {
headerTitle: "Обновлён",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "enabled",
accessorKey: "enabled",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => (
<span
className={`text-xs font-medium ${row.original.enabled ? "text-emerald-600" : "text-muted-foreground"}`}
>
{row.original.enabled ? "Активен" : "Отключён"}
</span>
),
meta: {
headerTitle: "Статус",
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[],
)
const table = useReactTable({
data: domains,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
...(pagination ? { getPaginationRowModel: getPaginationRowModel() } : {}),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={domains.length}
isLoading={isLoading}
loadingMode="skeleton"
pagination={pagination}
emptyMessage={
<EmptyState
icon={<GlobeIcon className="size-4" />}
title="Нет доменов"
description="Добавьте домены или импортируйте каталог"
className="border-0 py-12"
/>
}
/>
)
}
export { DomainsDataGrid, type DomainsDataGridProps }
+457
View File
@@ -0,0 +1,457 @@
"use client"
import { useMemo, useState } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { FilterRule, GreTunnel, Server } from "@/lib/data"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import {
AlertCircleIcon,
AlertTriangleIcon,
CheckCircle2Icon,
ChevronDownIcon,
ChevronUpIcon,
CircleDashedIcon,
PencilIcon,
RouteIcon,
StarIcon,
TrashIcon,
XCircleIcon,
FilterIcon,
} from "lucide-react"
export type FilterRouterSyncStatus = "synced" | "drift" | "missing"
export interface RecursiveRouteLite {
id: string
dstAddress: string
gateway: string
distance: number
routingTable: string
comment: string
disabled: boolean
}
function RouterSyncMarker({
status,
}: {
status: FilterRouterSyncStatus | null | "skip"
}) {
if (status === "skip") {
return <span className="size-3.5 shrink-0 block" aria-hidden />
}
const icon =
status === "synced"
? <CheckCircle2Icon className="size-3.5 text-emerald-600 dark:text-emerald-500 shrink-0" />
: status === "drift"
? <AlertTriangleIcon className="size-3.5 text-amber-500 shrink-0" />
: status === "missing"
? <XCircleIcon className="size-3.5 text-destructive shrink-0" />
: <CircleDashedIcon className="size-3.5 text-muted-foreground/35 shrink-0" />
const title =
status === "synced"
? "Совпадает с цепочкой bgp-in на MikroTik"
: status === "drift"
? "В БД и на роутере разное действие (gateway, blackhole или out-interface)"
: status === "missing"
? "Эта community не найдена в правиле bgp-in на роутере"
: "Не проверено — нажмите «Сверить с роутером»"
return (
<Tooltip>
<TooltipTrigger className="inline-flex cursor-default border-0 bg-transparent p-0">
{icon}
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{title}
</TooltipContent>
</Tooltip>
)
}
function innerIpToGateway(ip: string) {
return ip.split("/")[0]
}
function gatewayFromRecursiveDst(dstAddress: string) {
return innerIpToGateway((dstAddress ?? "").trim())
}
function pickRecursiveRouteByGatewayHop(routes: RecursiveRouteLite[], hopIp: string) {
const hop = hopIp.trim()
if (!hop) return undefined
const candidates = routes.filter(
(r) => !r.disabled && gatewayFromRecursiveDst(r.dstAddress) === hop,
)
if (candidates.length === 0) return undefined
return candidates.reduce((a, b) => (a.distance <= b.distance ? a : b))
}
function isRecursiveGatewayRef(ref: string) {
return ref.startsWith("rec:")
}
function FilterGatewayCell({
rule,
tunnelsList,
serversList,
recursiveRoutes,
}: {
rule: FilterRule
tunnelsList: GreTunnel[]
serversList: Server[]
recursiveRoutes: RecursiveRouteLite[]
}) {
const isBlackhole = rule.action === "blackhole"
const isRecRef = !isBlackhole && isRecursiveGatewayRef(rule.gatewayTunnelId)
const recRowByRef = isRecRef
? recursiveRoutes.find((r) => r.id === rule.gatewayTunnelId.slice(4))
: undefined
const recRowByHop =
!isBlackhole && !(rule.gatewayTunnelId ?? "").trim() && rule.gateway.trim()
? pickRecursiveRouteByGatewayHop(recursiveRoutes, rule.gateway)
: undefined
const recRow = recRowByRef ?? recRowByHop
const treatAsRecursive = !isBlackhole && (isRecRef || !!recRowByHop)
const tunnel =
!isBlackhole && !treatAsRecursive
? tunnelsList.find((t) => t.id === rule.gatewayTunnelId)
: undefined
const remoteSrv = tunnel ? serversList.find((s) => s.host === tunnel.remoteAddress) : undefined
if (isBlackhole) {
return (
<div className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full shrink-0 bg-red-500 animate-pulse" />
<span className="font-mono text-xs font-medium text-red-600 dark:text-red-400">type=blackhole</span>
</div>
)
}
return (
<div className="min-w-0 flex flex-col gap-0.5">
<div className="flex items-center gap-1.5 flex-wrap">
<span
className={cn(
"size-1.5 rounded-full shrink-0",
treatAsRecursive
? "bg-sky-500"
: tunnel?.status === "up"
? "bg-[var(--status-online)]"
: tunnel?.status === "degraded"
? "bg-[var(--status-degraded)]"
: "bg-[var(--status-offline)]",
)}
/>
{treatAsRecursive ? (
<>
<RouteIcon className="size-3 text-muted-foreground shrink-0" />
<span className="font-mono text-xs font-medium">
{recRow ? gatewayFromRecursiveDst(recRow.dstAddress) : rule.gateway}
</span>
<span className="text-[10px] font-medium text-muted-foreground border border-border rounded px-1 uppercase tracking-wide">
recursive
</span>
</>
) : (
<>
{remoteSrv && <Flag code={remoteSrv.country} />}
<span className="font-mono text-xs font-medium">{rule.gateway}</span>
</>
)}
</div>
{treatAsRecursive ? (
recRow ? (
<p className="text-[11px] text-muted-foreground truncate pl-3">{recRow.dstAddress}</p>
) : isRecRef ? (
<p className="text-[11px] text-amber-600 dark:text-amber-400 truncate pl-3">
рекурсивный маршрут (нет строки в списке)
</p>
) : null
) : tunnel ? (
<p className="text-[11px] text-muted-foreground truncate pl-3">{tunnel.name}</p>
) : null}
</div>
)
}
function FilterRowActions({
onEdit,
onDelete,
}: {
onEdit: () => void
onDelete: () => void
}) {
const [confirmDel, setConfirmDel] = useState(false)
return (
<div className="flex items-center gap-0.5 justify-end opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
<Button
size="sm"
variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onEdit}
>
<PencilIcon className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className={cn(
"size-7 p-0 transition-colors",
confirmDel
? "text-destructive bg-destructive/10 hover:bg-destructive/20"
: "text-muted-foreground hover:text-destructive",
)}
onClick={() => {
if (!confirmDel) setConfirmDel(true)
else onDelete()
}}
onBlur={() => setConfirmDel(false)}
>
{confirmDel ? <AlertCircleIcon className="size-3.5" /> : <TrashIcon className="size-3.5" />}
</Button>
</div>
)
}
interface FiltersDataGridProps {
rules: FilterRule[]
tunnelsList: GreTunnel[]
serversList: Server[]
communityNameMap: Record<string, string>
recursiveRoutes: RecursiveRouteLite[]
routerSyncByCommunity?: Record<string, FilterRouterSyncStatus> | null
isLive?: boolean
enableSorting?: boolean
onEdit: (rule: FilterRule) => void
onDelete: (id: string) => void
onMoveUp: (id: string) => void
onMoveDown: (id: string) => void
}
function FiltersDataGrid({
rules,
tunnelsList,
serversList,
communityNameMap,
recursiveRoutes,
routerSyncByCommunity,
isLive,
enableSorting = false,
onEdit,
onDelete,
onMoveUp,
onMoveDown,
}: FiltersDataGridProps) {
const columns = useMemo<ColumnDef<FilterRule>[]>(
() => [
{
id: "priority",
header: () => <span className="text-xs font-medium text-muted-foreground">#</span>,
enableSorting: false,
cell: ({ row, table }) => {
const isLast = row.index === table.getRowModel().rows.length - 1
return (
<div className="flex items-center justify-center text-[11px] font-mono text-muted-foreground/40">
{isLast ? (
<StarIcon className="size-3 text-amber-400 fill-amber-400" aria-label="Наивысший приоритет" />
) : (
<span>{row.index + 1}</span>
)}
</div>
)
},
size: 28,
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
},
{
id: "reorder",
header: () => <span className="sr-only">Порядок</span>,
enableSorting: false,
cell: ({ row, table }) => {
const isLast = row.index === table.getRowModel().rows.length - 1
return (
<div className="flex flex-col gap-px opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
<button
type="button"
onClick={() => onMoveUp(row.original.id)}
disabled={row.index === 0}
className="text-muted-foreground/50 hover:text-foreground disabled:opacity-20"
>
<ChevronUpIcon className="size-3" />
</button>
<button
type="button"
onClick={() => onMoveDown(row.original.id)}
disabled={isLast}
className="text-muted-foreground/50 hover:text-foreground disabled:opacity-20"
>
<ChevronDownIcon className="size-3" />
</button>
</div>
)
},
size: 28,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "routerSync",
header: () => (
<Tooltip>
<TooltipTrigger className="cursor-help font-mono text-xs text-muted-foreground border-0 bg-transparent p-0">
MT
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
Совпадение с MikroTik (bgp-in)
</TooltipContent>
</Tooltip>
),
enableSorting: false,
cell: ({ row }) => (
<RouterSyncMarker
status={
!isLive
? "skip"
: !routerSyncByCommunity
? null
: routerSyncByCommunity[row.original.community.trim()] ?? null
}
/>
),
size: 32,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "community",
accessorKey: "community",
header: ({ column }) => <DataGridSortHeader column={column} title="Community" />,
enableSorting,
cell: ({ row }) => {
const rule = row.original
const isBlackhole = rule.action === "blackhole"
const communityName = communityNameMap[rule.community] ?? rule.communityName
return (
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span
className={cn(
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border font-mono",
isBlackhole
? "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/25"
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
)}
>
{rule.community}
</span>
{isBlackhole && (
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold border bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20 uppercase">
blackhole
</span>
)}
</div>
{communityName && (
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">{communityName}</p>
)}
</div>
)
},
meta: { headerTitle: "Community", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "gateway",
accessorKey: "gateway",
header: ({ column }) => <DataGridSortHeader column={column} title="Gateway" />,
enableSorting,
cell: ({ row }) => (
<FilterGatewayCell
rule={row.original}
tunnelsList={tunnelsList}
serversList={serversList}
recursiveRoutes={recursiveRoutes}
/>
),
meta: { headerTitle: "Gateway", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "description",
accessorKey: "description",
header: ({ column }) => <DataGridSortHeader column={column} title="Описание" />,
enableSorting,
cell: ({ row }) => (
<p className="text-xs text-muted-foreground truncate">{row.original.description || "—"}</p>
),
meta: { headerTitle: "Описание", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
enableSorting: false,
cell: ({ row }) => (
<FilterRowActions
onEdit={() => onEdit(row.original)}
onDelete={() => onDelete(row.original.id)}
/>
),
size: 64,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[
communityNameMap,
enableSorting,
isLive,
onDelete,
onEdit,
onMoveDown,
onMoveUp,
recursiveRoutes,
routerSyncByCommunity,
serversList,
tunnelsList,
],
)
const table = useReactTable({
data: rules,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (rules.length === 0) {
return (
<EmptyState
icon={<FilterIcon className="size-4" />}
title="Ничего не найдено"
className="border-0 py-16"
/>
)
}
return (
<>
<DataGridShell table={table} recordCount={rules.length} />
<div className="px-5 py-2 text-[11px] text-muted-foreground/40 flex items-center gap-1.5 border-t">
<StarIcon className="size-3 text-amber-400 fill-amber-400 shrink-0" />
Последнее правило имеет наивысший приоритет в RouterOS
</div>
</>
)
}
export { FiltersDataGrid, RouterSyncMarker, type FiltersDataGridProps }
@@ -0,0 +1,311 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { FirewallRule } from "@/lib/data"
import { FormToggle } from "@/components/form-kit"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import {
CopyIcon,
MoreHorizontalIcon,
PencilIcon,
PowerIcon,
ShieldOffIcon,
Trash2Icon,
} from "lucide-react"
const ACTION_STYLES: Record<string, string> = {
accept: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
drop: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
reject: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
masquerade: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
"mark-routing": "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
"mark-conn": "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
"fasttrack-connection": "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
"dst-nat": "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
"src-nat": "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
"add-src-to-address-list": "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20",
}
const CHAIN_STYLES: Record<string, string> = {
forward: "bg-foreground/5 text-foreground/70",
input: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
output: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
srcnat: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
dstnat: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
prerouting: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
postrouting: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
"ip6-input": "bg-violet-500/10 text-violet-600 dark:text-violet-400",
"ip6-forward": "bg-foreground/5 text-foreground/70",
"ip6-output": "bg-sky-500/10 text-sky-600 dark:text-sky-400",
}
function fmtHits(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}М`
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}к`
return String(n)
}
function ActionBadge({ action }: { action: string }) {
const cls = ACTION_STYLES[action] ?? "bg-muted text-muted-foreground border-border"
return (
<span className={cn("text-[11px] font-mono font-medium px-2 py-0.5 rounded border whitespace-nowrap", cls)}>
{action}
</span>
)
}
function ChainBadge({ chain }: { chain: string }) {
const cls = CHAIN_STYLES[chain] ?? "bg-muted text-muted-foreground"
return (
<span className={cn("text-[11px] font-mono px-2 py-0.5 rounded", cls)}>
{chain}
</span>
)
}
interface FirewallRulesDataGridProps {
rules: FirewallRule[]
onToggle: (id: string) => void
onEdit: (rule: FirewallRule) => void
}
function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGridProps) {
const indexedRules = useMemo(
() => rules.map((rule, index) => ({ ...rule, _index: index + 1 })),
[rules],
)
const columns = useMemo<ColumnDef<FirewallRule & { _index: number }>[]>(
() => [
{
id: "index",
accessorKey: "_index",
header: () => <span className="text-xs font-medium text-muted-foreground">#</span>,
enableSorting: false,
cell: ({ row }) => (
<span
className="font-mono text-xs text-muted-foreground"
data-rule-disabled={!row.original.enabled ? true : undefined}
>
{row.original._index}
</span>
),
size: 48,
meta: {
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "chain",
accessorKey: "chain",
header: ({ column }) => <DataGridSortHeader column={column} title="Цепочка" />,
cell: ({ row }) => <ChainBadge chain={row.original.chain} />,
meta: { headerTitle: "Цепочка", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "action",
accessorKey: "action",
header: ({ column }) => <DataGridSortHeader column={column} title="Действие" />,
cell: ({ row }) => <ActionBadge action={row.original.action} />,
meta: { headerTitle: "Действие", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "src",
accessorKey: "src",
header: ({ column }) => <DataGridSortHeader column={column} title="Источник" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground max-w-[140px] truncate block">
{row.original.src || "any"}
</span>
),
meta: { headerTitle: "Источник", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "dst",
accessorKey: "dst",
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground max-w-[140px] truncate block">
{row.original.dst || "any"}
</span>
),
meta: { headerTitle: "Назначение", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "proto",
accessorKey: "proto",
header: ({ column }) => <DataGridSortHeader column={column} title="Протокол" />,
cell: ({ row }) => <span className="text-xs font-mono">{row.original.proto}</span>,
meta: { headerTitle: "Протокол", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "port",
accessorKey: "port",
header: ({ column }) => <DataGridSortHeader column={column} title="Порт" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.port || "—"}</span>
),
meta: { headerTitle: "Порт", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "iface",
accessorKey: "iface",
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.iface || "—"}</span>
),
meta: { headerTitle: "Интерфейс", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "hits",
accessorKey: "hits",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Пакетов" className="ml-auto" />
),
cell: ({ row }) => {
const hits = row.original.hits
return (
<span
className={cn(
"text-xs font-mono tabular-nums text-right block",
hits > 1_000_000
? "text-emerald-600 dark:text-emerald-400 font-semibold"
: hits > 10_000
? "text-foreground"
: "text-muted-foreground",
)}
>
{fmtHits(hits)}
</span>
)
},
meta: {
headerTitle: "Пакетов",
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
},
},
{
id: "enabled",
accessorKey: "enabled",
header: () => <span className="text-xs font-medium text-muted-foreground">Вкл</span>,
enableSorting: false,
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()}>
<FormToggle checked={row.original.enabled} onChange={() => onToggle(row.original.id)} />
</div>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
enableSorting: false,
cell: ({ row }) => {
const r = row.original
return (
<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="outline"
size="icon"
className={cn(
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
"opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100",
"data-popup-open:opacity-100",
)}
aria-label={`Действия: ${r.chain} ${r.action}`}
>
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={() => onEdit(r)}>
<PencilIcon className="size-4" />
Редактировать
</DropdownMenuItem>
<DropdownMenuItem>
<CopyIcon className="size-4" />
Дублировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onToggle(r.id)}>
<PowerIcon className="size-4" />
{r.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" />
Удалить правило
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
size: 56,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[onEdit, onToggle],
)
const table = useReactTable({
data: indexedRules,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (rules.length === 0) {
return (
<EmptyState
icon={<ShieldOffIcon className="size-4" />}
title="Правила не найдены"
description="Попробуйте изменить фильтр или добавьте новое правило"
className="border-0 py-16"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={rules.length}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: cn("group/row", "[&:has([data-rule-disabled=true])]:opacity-40"),
}}
/>
)
}
export { FirewallRulesDataGrid, type FirewallRulesDataGridProps, ActionBadge, ChainBadge }
@@ -0,0 +1,216 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import { ActionBadge, ChainBadge } from "@/components/data-grids/firewall-rules-data-grid"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { cn } from "@/lib/utils"
import { PowerIcon, Trash2Icon } from "lucide-react"
function ScenarioCell({ enabled, children }: { enabled: boolean; children: React.ReactNode }) {
return <div className={cn(!enabled && "opacity-40")}>{children}</div>
}
export interface ScenarioRuleRow {
id: string
chain: string
action: string
proto: string
src: string
dst: string
port: string
iface: string
comment: string
enabled: boolean
}
interface FirewallScenarioRulesDataGridProps {
rules: ScenarioRuleRow[]
onToggleEnabled: (id: string) => void
onMoveUp: (id: string) => void
onMoveDown: (id: string) => void
onRemove: (id: string) => void
}
function FirewallScenarioRulesDataGrid({
rules,
onToggleEnabled,
onMoveUp,
onMoveDown,
onRemove,
}: FirewallScenarioRulesDataGridProps) {
const indexedRules = useMemo(
() => rules.map((rule, index) => ({ ...rule, _index: index + 1 })),
[rules],
)
const columns = useMemo<ColumnDef<ScenarioRuleRow & { _index: number }>[]>(
() => [
{
id: "index",
accessorKey: "_index",
header: () => <span className="text-xs font-medium text-muted-foreground">#</span>,
enableSorting: false,
cell: ({ row }) => (
<ScenarioCell enabled={row.original.enabled}>
<span className="font-mono text-xs text-muted-foreground tabular-nums">{row.original._index}</span>
</ScenarioCell>
),
size: 40,
meta: {
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "chain",
accessorKey: "chain",
header: () => <span className="text-xs font-medium text-muted-foreground">Цепочка</span>,
enableSorting: false,
cell: ({ row }) => (
<ScenarioCell enabled={row.original.enabled}>
<ChainBadge chain={row.original.chain} />
</ScenarioCell>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "action",
accessorKey: "action",
header: () => <span className="text-xs font-medium text-muted-foreground">Действие</span>,
enableSorting: false,
cell: ({ row }) => <ActionBadge action={row.original.action} />,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "src",
accessorKey: "src",
header: () => <span className="text-xs font-medium text-muted-foreground">Src</span>,
enableSorting: false,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground max-w-[90px] truncate block">
{row.original.src || "any"}
</span>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "dst",
accessorKey: "dst",
header: () => <span className="text-xs font-medium text-muted-foreground">Dst</span>,
enableSorting: false,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground max-w-[90px] truncate block">
{row.original.dst || "any"}
</span>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "port",
accessorKey: "port",
header: () => <span className="text-xs font-medium text-muted-foreground">Порт</span>,
enableSorting: false,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.port || "—"}</span>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "iface",
accessorKey: "iface",
header: () => <span className="text-xs font-medium text-muted-foreground">Iface</span>,
enableSorting: false,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.iface || "—"}</span>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "comment",
accessorKey: "comment",
header: () => <span className="text-xs font-medium text-muted-foreground">Комментарий</span>,
enableSorting: false,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground/70 max-w-[110px] truncate block">
{row.original.comment || "—"}
</span>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "actions",
header: () => null,
enableSorting: false,
cell: ({ row }) => {
const index = row.original._index - 1
return (
<div className="flex items-center gap-0.5 justify-end opacity-0 group-hover/row:opacity-100 transition-opacity">
<button
type="button"
onClick={() => onToggleEnabled(row.original.id)}
title={row.original.enabled ? "Отключить" : "Включить"}
className="p-0.5 text-muted-foreground/40 hover:text-foreground transition-colors"
>
<PowerIcon className="size-3.5" />
</button>
<button
type="button"
onClick={() => onMoveUp(row.original.id)}
disabled={index === 0}
className="p-0.5 text-muted-foreground/40 hover:text-foreground disabled:opacity-20 transition-colors"
>
</button>
<button
type="button"
onClick={() => onMoveDown(row.original.id)}
disabled={index === rules.length - 1}
className="p-0.5 text-muted-foreground/40 hover:text-foreground disabled:opacity-20 transition-colors"
>
</button>
<button
type="button"
onClick={() => onRemove(row.original.id)}
className="p-0.5 ml-0.5 text-muted-foreground/40 hover:text-red-500 transition-colors"
>
<Trash2Icon className="size-3.5" />
</button>
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[onMoveDown, onMoveUp, onRemove, onToggleEnabled, rules.length],
)
const table = useReactTable({
data: indexedRules,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={rules.length}
tableClassNames={{
bodyRow: cn("group/row text-xs", "hover:bg-muted/20 transition-colors"),
}}
/>
)
}
export { FirewallScenarioRulesDataGrid, type FirewallScenarioRulesDataGridProps }
@@ -0,0 +1,202 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { GrePool } from "@/lib/data"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { LayersIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react"
interface GrePoolsDataGridProps {
pools: GrePool[]
}
function GrePoolsDataGrid({ pools }: GrePoolsDataGridProps) {
const columns = useMemo<ColumnDef<GrePool>[]>(
() => [
{
id: "name",
accessorKey: "name",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Имя пула" className="ml-1" />
),
cell: ({ row }) => (
<span className="font-mono text-[13px] font-medium">{row.original.name}</span>
),
meta: {
headerTitle: "Имя пула",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "cidr",
accessorKey: "cidr",
header: ({ column }) => <DataGridSortHeader column={column} title="Диапазон CIDR" />,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.cidr}</span>,
meta: {
headerTitle: "Диапазон CIDR",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "allocated",
accessorKey: "allocated",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Назначено /30" className="ml-auto" />
),
cell: ({ row }) => (
<span className="tabular-nums text-right block">{row.original.allocated}</span>
),
meta: {
headerTitle: "Назначено /30",
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
},
},
{
id: "available",
header: () => (
<span className="text-xs font-medium text-muted-foreground block text-right">
Доступно /30
</span>
),
enableSorting: false,
cell: ({ row }) => {
const p = row.original
return (
<span className="tabular-nums text-muted-foreground text-right block">
{p.total - p.allocated}
</span>
)
},
meta: {
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
},
},
{
id: "usage",
header: () => <span className="text-xs font-medium text-muted-foreground">Использование</span>,
enableSorting: false,
cell: ({ row }) => {
const pool = row.original
const pct = pool.total > 0 ? Math.round((pool.allocated / pool.total) * 100) : 0
return (
<div className="flex items-center gap-2 min-w-[140px]">
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className={cn("h-full rounded-full", pct > 80 ? "bg-amber-500" : "bg-emerald-500")}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums w-8 text-right">{pct}%</span>
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "comment",
accessorKey: "comment",
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
cell: ({ row }) => (
<span className="text-muted-foreground text-xs">{row.original.comment}</span>
),
meta: {
headerTitle: "Назначение",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
enableSorting: false,
cell: () => (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="outline"
size="icon"
className={cn(
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
"opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100",
"data-popup-open:opacity-100",
)}
>
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem>
<PencilIcon className="size-4" /> Редактировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" /> Удалить пул
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
size: 56,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[],
)
const table = useReactTable({
data: pools,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (pools.length === 0) {
return (
<EmptyState
icon={<LayersIcon className="size-4" />}
title="Нет IP-пулов"
className="border-0 py-16"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={pools.length}
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row" }}
/>
)
}
export { GrePoolsDataGrid, type GrePoolsDataGridProps }
@@ -0,0 +1,352 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type {
GrePool,
GreTunnel,
GreStatus,
IpsecEncAlg,
IpsecAuthAlg,
IpsecDhGroup,
IkeVersion,
Server,
} from "@/lib/data"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import {
CodeXmlIcon,
LockIcon,
LockOpenIcon,
MoreHorizontalIcon,
NetworkIcon,
PencilIcon,
PowerIcon,
Trash2Icon,
} from "lucide-react"
const ENC_LABELS: Record<IpsecEncAlg, string> = {
"aes-128": "AES-128",
"aes-192": "AES-192",
"aes-256": "AES-256",
}
const AUTH_LABELS: Record<IpsecAuthAlg, string> = {
sha1: "SHA-1",
sha256: "SHA-256",
sha512: "SHA-512",
}
const DH_LABELS: Record<IpsecDhGroup, string> = {
modp1024: "DH-2 (1024)",
modp2048: "DH-14 (2048)",
modp4096: "DH-16 (4096)",
ecp256: "ECP-256",
ecp384: "ECP-384",
ecp521: "ECP-521",
}
const IKE_LABELS: Record<IkeVersion, string> = { ikev1: "IKEv1", ikev2: "IKEv2" }
const STATUS_MAP: Record<GreStatus, { label: string; dot: string }> = {
up: { label: "Up", dot: "bg-emerald-500" },
degraded: { label: "Degraded", dot: "bg-amber-500" },
down: { label: "Down", dot: "bg-red-500" },
}
function TunnelStatus({ status }: { status: GreStatus }) {
const s = STATUS_MAP[status]
return (
<span className="inline-flex items-center gap-1.5 text-sm">
<span className={cn("size-1.5 rounded-full", s.dot)} />
{s.label}
</span>
)
}
function IpsecBadge({ secured }: { secured: boolean }) {
return secured ? (
<span className="inline-flex items-center gap-1 text-xs font-medium border rounded px-2 py-0.5 bg-emerald-500/10 text-emerald-400 border-emerald-500/20">
<LockIcon className="size-3" /> IPsec
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs font-medium border rounded px-2 py-0.5 bg-muted text-muted-foreground border-border">
<LockOpenIcon className="size-3" /> Открытый
</span>
)
}
interface GreTunnelsDataGridProps {
tunnels: GreTunnel[]
servers: Server[]
pools: GrePool[]
onCodePreview: (tunnel: GreTunnel) => void
}
function GreTunnelsDataGrid({
tunnels,
servers,
pools,
onCodePreview,
}: GreTunnelsDataGridProps) {
const serverMap = useMemo(() => new Map(servers.map((s) => [s.id, s])), [servers])
const poolMap = useMemo(() => new Map(pools.map((p) => [p.id, p])), [pools])
const columns = useMemo<ColumnDef<GreTunnel>[]>(
() => [
{
id: "name",
accessorKey: "name",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Интерфейс / Сервер" className="ml-1" />
),
cell: ({ row }) => {
const t = row.original
const srv = serverMap.get(t.serverId)
return (
<div className="min-w-0">
<p className="font-medium font-mono text-[13px]">{t.name}</p>
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
{srv && <Flag code={srv.country} />}
{srv?.name ?? t.serverId}
</p>
</div>
)
},
meta: {
headerTitle: "Интерфейс / Сервер",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "endpoints",
header: () => <span className="text-xs font-medium text-muted-foreground">Эндпоинты</span>,
enableSorting: false,
cell: ({ row }) => {
const t = row.original
return (
<div>
<p className="font-mono text-xs">
{t.localAddress === "0.0.0.0" ? (
<span className="text-muted-foreground">авто</span>
) : (
t.localAddress
)}
</p>
<p className="font-mono text-xs text-muted-foreground"> {t.remoteAddress}</p>
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "innerIp",
header: () => <span className="text-xs font-medium text-muted-foreground">Внутренний IP</span>,
enableSorting: false,
cell: ({ row }) => {
const t = row.original
return (
<div>
<p className="font-mono text-xs">{t.localInnerIp}</p>
<p className="font-mono text-xs text-muted-foreground">{t.remoteInnerIp}</p>
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "pool",
accessorKey: "poolId",
header: ({ column }) => <DataGridSortHeader column={column} title="Пул" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground font-mono">
{poolMap.get(row.original.poolId)?.name ?? "—"}
</span>
),
meta: { headerTitle: "Пул", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "ipsec",
header: () => <span className="text-xs font-medium text-muted-foreground">IPsec</span>,
enableSorting: false,
cell: ({ row }) => <IpsecBadge secured={!!row.original.ipsec} />,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "encryption",
header: () => <span className="text-xs font-medium text-muted-foreground">Шифрование</span>,
enableSorting: false,
cell: ({ row }) => {
const t = row.original
if (!t.ipsec) return <span className="text-xs text-muted-foreground"></span>
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs font-mono">
{ENC_LABELS[t.ipsec.encAlg]} / {AUTH_LABELS[t.ipsec.authAlg]}
</span>
<span className="text-xs text-muted-foreground font-mono">
{DH_LABELS[t.ipsec.dhGroup].split(" ")[0]} · {IKE_LABELS[t.ipsec.ikeVersion]}
{t.ipsec.pfs && " · PFS"}
</span>
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "mtu",
accessorKey: "mtu",
header: ({ column }) => <DataGridSortHeader column={column} title="MTU" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-center block">{row.original.mtu}</span>
),
meta: {
headerTitle: "MTU",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "keepalive",
header: () => <span className="text-xs font-medium text-muted-foreground">Keepalive</span>,
enableSorting: false,
cell: ({ row }) => {
const t = row.original
return (
<span className="font-mono text-xs text-muted-foreground">
{t.keepaliveInterval === 0 ? "откл." : `${t.keepaliveInterval}с / ${t.keepaliveRetries}`}
</span>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "status",
accessorKey: "status",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => <TunnelStatus status={row.original.status} />,
meta: { headerTitle: "Статус", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
enableSorting: false,
cell: ({ row }) => {
const t = row.original
return (
<div className="flex items-center gap-1 justify-end">
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"size-7 opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100",
)}
title="Предпросмотр кода RouterOS"
onClick={(e) => {
e.stopPropagation()
onCodePreview(t)
}}
>
<CodeXmlIcon className="size-3.5" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="outline"
size="icon"
className={cn(
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
"opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100",
"data-popup-open:opacity-100",
)}
aria-label={`Действия: ${t.name}`}
>
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuGroup>
<DropdownMenuLabel>{t.name}</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onCodePreview(t)}>
<CodeXmlIcon className="size-4" /> Просмотр кода
</DropdownMenuItem>
<DropdownMenuItem>
<PencilIcon className="size-4" /> Редактировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<PowerIcon className="size-4" />
{t.enabled ? "Выключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" /> Удалить туннель
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
size: 88,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[onCodePreview, poolMap, serverMap],
)
const table = useReactTable({
data: tunnels,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row, index) => `${row.id}:${row.serverId}:${row.name}:${index}`,
})
if (tunnels.length === 0) {
return (
<EmptyState
icon={<NetworkIcon className="size-4" />}
title="Нет GRE-туннелей"
description="Измените фильтр или добавьте туннель"
className="border-0 py-16"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={tunnels.length}
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row" }}
/>
)
}
export { GreTunnelsDataGrid, type GreTunnelsDataGridProps }
@@ -0,0 +1,157 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { IpRange } from "@/lib/data"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { FilterIcon, NetworkIcon } from "lucide-react"
interface IpRangesDataGridProps {
ipRanges: IpRange[]
isLoading?: boolean
pagination?: boolean
}
function IpRangesDataGrid({ ipRanges, isLoading, pagination = false }: IpRangesDataGridProps) {
const columns = useMemo<ColumnDef<IpRange>[]>(
() => [
{
id: "cidr",
accessorKey: "cidr",
header: ({ column }) => <DataGridSortHeader column={column} title="CIDR" className="ml-1" />,
cell: ({ row }) => <span className="font-mono font-medium">{row.original.cidr}</span>,
meta: {
headerTitle: "CIDR",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "asn",
accessorKey: "asn",
header: ({ column }) => <DataGridSortHeader column={column} title="ASN" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.asn}</span>
),
meta: {
headerTitle: "ASN",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "country",
accessorKey: "country",
header: ({ column }) => <DataGridSortHeader column={column} title="Страна" />,
cell: ({ row }) => (
<span className="text-xs border border-border rounded px-2 py-0.5">{row.original.country}</span>
),
meta: {
headerTitle: "Страна",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "purpose",
accessorKey: "purpose",
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
cell: ({ row }) => (
<span className="text-xs border border-border rounded px-2 py-0.5">{row.original.purpose}</span>
),
meta: {
headerTitle: "Назначение",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "filter",
accessorKey: "filter",
header: ({ column }) => <DataGridSortHeader column={column} title="Фильтр" />,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
<FilterIcon className="size-3 text-muted-foreground" />
{row.original.filter}
</span>
),
meta: {
headerTitle: "Фильтр",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "updated",
accessorKey: "updated",
header: ({ column }) => <DataGridSortHeader column={column} title="Обновлён" />,
cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.original.updated}</span>,
meta: {
headerTitle: "Обновлён",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "enabled",
accessorKey: "enabled",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => (
<span
className={`text-xs font-medium ${row.original.enabled ? "text-emerald-600" : "text-muted-foreground"}`}
>
{row.original.enabled ? "Активен" : "Отключён"}
</span>
),
meta: {
headerTitle: "Статус",
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[],
)
const table = useReactTable({
data: ipRanges,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
...(pagination ? { getPaginationRowModel: getPaginationRowModel() } : {}),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={ipRanges.length}
isLoading={isLoading}
loadingMode="skeleton"
pagination={pagination}
emptyMessage={
<EmptyState
icon={<NetworkIcon className="size-4" />}
title="Нет IP-диапазонов"
description="Добавьте CIDR-блоки или импортируйте каталог"
className="border-0 py-12"
/>
}
/>
)
}
export { IpRangesDataGrid, type IpRangesDataGridProps }
@@ -0,0 +1,298 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { ActivityIcon } from "lucide-react"
export interface BfdSessionRow {
id: string
serverId: string
serverLabel: string
localAddr: string
remoteAddr: string
state: "Up" | "Down" | "Init" | "AdminDown"
interval: number
multiplier: number
iface: string
uptime: string | null
multihop: boolean
rxInterval: number
holdTime: number
packetsRx: number
packetsTx: number
stateChanges: number
}
function stateClass(state: BfdSessionRow["state"]) {
if (state === "Up")
return "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/25"
if (state === "Init")
return "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/25"
return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25"
}
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
return (
<span
className={cn(
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border",
color ?? "bg-muted text-muted-foreground border-border",
)}
>
{children}
</span>
)
}
function fmtMs(ms: number) {
if (!ms) return "—"
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
interface OspfBfdDataGridProps {
sessions: BfdSessionRow[]
}
function OspfBfdDataGrid({ sessions }: OspfBfdDataGridProps) {
const columns = useMemo<ColumnDef<BfdSessionRow>[]>(
() => [
{
id: "serverLabel",
accessorKey: "serverLabel",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Роутер" className="ml-1" />
),
cell: ({ row }) => {
const b = row.original
return (
<div className="flex flex-col gap-0.5">
<span className="font-mono whitespace-nowrap">{b.serverLabel}</span>
{b.multihop && (
<Chip color="bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20">
multihop
</Chip>
)}
</div>
)
},
meta: {
headerTitle: "Роутер",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "iface",
accessorKey: "iface",
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
cell: ({ row }) => (
<span className="font-mono text-muted-foreground whitespace-nowrap">
{row.original.iface || "—"}
</span>
),
meta: {
headerTitle: "Интерфейс",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "localAddr",
accessorKey: "localAddr",
header: ({ column }) => <DataGridSortHeader column={column} title="Локальный" />,
cell: ({ row }) => (
<span className="font-mono whitespace-nowrap">{row.original.localAddr}</span>
),
meta: {
headerTitle: "Локальный",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "remoteAddr",
accessorKey: "remoteAddr",
header: ({ column }) => <DataGridSortHeader column={column} title="Удалённый" />,
cell: ({ row }) => (
<span className="font-mono whitespace-nowrap">{row.original.remoteAddr}</span>
),
meta: {
headerTitle: "Удалённый",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "state",
accessorKey: "state",
header: ({ column }) => <DataGridSortHeader column={column} title="Состояние" />,
cell: ({ row }) => (
<span
className={cn(
"inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium",
stateClass(row.original.state),
)}
>
{row.original.state}
</span>
),
meta: {
headerTitle: "Состояние",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "uptime",
accessorKey: "uptime",
header: ({ column }) => <DataGridSortHeader column={column} title="Uptime" />,
cell: ({ row }) => (
<span className="text-muted-foreground whitespace-nowrap tabular-nums">
{row.original.uptime ?? "—"}
</span>
),
meta: {
headerTitle: "Uptime",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "intervals",
header: () => <span className="text-xs font-medium text-muted-foreground">Tx / Rx</span>,
enableSorting: false,
cell: ({ row }) => {
const b = row.original
return (
<span className="font-mono tabular-nums text-muted-foreground whitespace-nowrap">
{fmtMs(b.interval)} / {fmtMs(b.rxInterval)}
</span>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "holdTime",
accessorKey: "holdTime",
header: ({ column }) => <DataGridSortHeader column={column} title="Hold" />,
cell: ({ row }) => (
<span className="font-mono tabular-nums text-muted-foreground whitespace-nowrap">
{fmtMs(row.original.holdTime)}
</span>
),
meta: {
headerTitle: "Hold",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "multiplier",
accessorKey: "multiplier",
header: ({ column }) => <DataGridSortHeader column={column} title="Mult" />,
cell: ({ row }) => (
<span className="font-mono tabular-nums text-center block">{row.original.multiplier}</span>
),
meta: {
headerTitle: "Mult",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "packetsRx",
accessorKey: "packetsRx",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Пакеты Rx" className="ml-auto" />
),
cell: ({ row }) => (
<span className="font-mono tabular-nums text-right text-muted-foreground block">
{row.original.packetsRx.toLocaleString()}
</span>
),
meta: {
headerTitle: "Пакеты Rx",
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
},
},
{
id: "packetsTx",
accessorKey: "packetsTx",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Пакеты Tx" className="ml-auto" />
),
cell: ({ row }) => (
<span className="font-mono tabular-nums text-right text-muted-foreground block">
{row.original.packetsTx.toLocaleString()}
</span>
),
meta: {
headerTitle: "Пакеты Tx",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
},
},
{
id: "stateChanges",
accessorKey: "stateChanges",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Переходы" className="ml-auto" />
),
cell: ({ row }) => (
<span className="font-mono tabular-nums text-right block">{row.original.stateChanges}</span>
),
meta: {
headerTitle: "Переходы",
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-right"),
},
},
],
[],
)
const table = useReactTable({
data: sessions,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (sessions.length === 0) {
return (
<EmptyState
icon={<ActivityIcon className="size-4" />}
title="BFD-сессий не обнаружено"
className="border-0 py-10"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={sessions.length}
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row hover:bg-muted/30" }}
tableLayout={{ dense: true }}
/>
)
}
export { OspfBfdDataGrid, type OspfBfdDataGridProps }
@@ -0,0 +1,238 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { NetworkIcon } from "lucide-react"
export interface OspfNeighborRow {
id: string
localRouter: string
localLabel: string
localIface: string
remoteRouter: string
remoteLabel: string
remoteRouterId: string
area: string
state: "Full" | "2-Way" | "ExStart" | "Down"
cost: number
uptime: string
priority: number
}
function stateClass(state: OspfNeighborRow["state"]) {
if (state === "Full")
return "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/25"
if (state === "2-Way")
return "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/25"
return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25"
}
interface OspfNeighborsDataGridProps {
neighbors: OspfNeighborRow[]
highlightRouterId?: string | null
selectedRouterId?: string | null
onHighlight?: (routerId: string | null) => void
onSelect?: (routerId: string | null) => void
}
function OspfNeighborsDataGrid({
neighbors,
highlightRouterId,
selectedRouterId,
onHighlight,
onSelect,
}: OspfNeighborsDataGridProps) {
const columns = useMemo<ColumnDef<OspfNeighborRow>[]>(
() => [
{
id: "localLabel",
accessorKey: "localLabel",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Роутер" className="ml-1" />
),
cell: ({ row }) => (
<span className="font-mono whitespace-nowrap">{row.original.localLabel}</span>
),
meta: {
headerTitle: "Роутер",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "localIface",
accessorKey: "localIface",
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
cell: ({ row }) => (
<span className="font-mono text-muted-foreground whitespace-nowrap">
{row.original.localIface}
</span>
),
meta: {
headerTitle: "Интерфейс",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "remote",
header: () => (
<span className="text-xs font-medium text-muted-foreground">Сосед (Router ID)</span>
),
enableSorting: false,
cell: ({ row }) => {
const n = row.original
return (
<div className="flex flex-col">
<span className="font-mono">
{n.remoteLabel !== n.remoteRouterId ? n.remoteLabel : n.remoteRouterId}
</span>
{n.remoteLabel !== n.remoteRouterId && (
<span className="text-[10px] font-mono text-muted-foreground">{n.remoteRouterId}</span>
)}
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "area",
accessorKey: "area",
header: ({ column }) => <DataGridSortHeader column={column} title="Область" />,
cell: ({ row }) => (
<span className="font-mono text-muted-foreground">{row.original.area}</span>
),
meta: {
headerTitle: "Область",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "state",
accessorKey: "state",
header: ({ column }) => <DataGridSortHeader column={column} title="Состояние" />,
cell: ({ row }) => (
<span
className={cn(
"inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium",
stateClass(row.original.state),
)}
>
{row.original.state}
</span>
),
meta: {
headerTitle: "Состояние",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "cost",
accessorKey: "cost",
header: ({ column }) => <DataGridSortHeader column={column} title="Cost" />,
cell: ({ row }) => (
<span className="font-mono tabular-nums text-center block">{row.original.cost}</span>
),
meta: {
headerTitle: "Cost",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "uptime",
accessorKey: "uptime",
header: ({ column }) => <DataGridSortHeader column={column} title="Uptime" />,
cell: ({ row }) => (
<span className="text-muted-foreground whitespace-nowrap tabular-nums">
{row.original.uptime}
</span>
),
meta: {
headerTitle: "Uptime",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "priority",
accessorKey: "priority",
header: ({ column }) => <DataGridSortHeader column={column} title="Prio" />,
cell: ({ row }) => (
<span className="text-center font-mono block">{row.original.priority}</span>
),
meta: {
headerTitle: "Prio",
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-center"),
},
},
],
[],
)
const rowSelection = useMemo(() => {
if (!selectedRouterId) return {}
const match = neighbors.find(
(n) => n.localRouter === selectedRouterId || n.remoteRouter === selectedRouterId,
)
return match ? { [match.id]: true } : {}
}, [neighbors, selectedRouterId])
const table = useReactTable({
data: neighbors,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
enableRowSelection: true,
state: { rowSelection },
})
if (neighbors.length === 0) {
return (
<EmptyState
icon={<NetworkIcon className="size-4" />}
title="Нет OSPF-соседей"
className="border-0 py-10"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={neighbors.length}
onRowClick={(row) => {
onSelect?.(selectedRouterId === row.localRouter ? null : row.localRouter)
}}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: cn(
"group/row cursor-pointer",
"data-[state=selected]:bg-primary/5",
highlightRouterId && "[&:hover]:bg-muted/30",
),
}}
tableLayout={{ dense: true }}
/>
)
}
export { OspfNeighborsDataGrid, type OspfNeighborsDataGridProps }
@@ -0,0 +1,171 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { RouteIcon } from "lucide-react"
export interface OspfRouteRow {
id: string
destination: string
type: "O" | "O IA" | "O E1" | "O E2"
cost: number
nextHop: string
via: string
serverId: string
serverLabel: string
area: string
}
function routeTypeClass(type: OspfRouteRow["type"]) {
if (type === "O") return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25"
if (type === "O IA") return "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/25"
if (type === "O E1") return "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/25"
return "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/25"
}
interface OspfRoutesDataGridProps {
routes: OspfRouteRow[]
}
function OspfRoutesDataGrid({ routes }: OspfRoutesDataGridProps) {
const columns = useMemo<ColumnDef<OspfRouteRow>[]>(
() => [
{
id: "destination",
accessorKey: "destination",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Назначение" className="ml-1" />
),
cell: ({ row }) => <span className="font-mono">{row.original.destination}</span>,
meta: {
headerTitle: "Назначение",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "type",
accessorKey: "type",
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
cell: ({ row }) => (
<span
className={cn(
"inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium",
routeTypeClass(row.original.type),
)}
>
{row.original.type}
</span>
),
meta: { headerTitle: "Тип", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "cost",
accessorKey: "cost",
header: ({ column }) => <DataGridSortHeader column={column} title="Cost" />,
cell: ({ row }) => (
<span className="font-mono tabular-nums text-center block">{row.original.cost}</span>
),
meta: {
headerTitle: "Cost",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "nextHop",
accessorKey: "nextHop",
header: ({ column }) => <DataGridSortHeader column={column} title="Следующий хоп" />,
cell: ({ row }) => (
<span className="font-mono text-muted-foreground">{row.original.nextHop}</span>
),
meta: {
headerTitle: "Следующий хоп",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "via",
accessorKey: "via",
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
cell: ({ row }) => (
<span className="font-mono text-muted-foreground">{row.original.via}</span>
),
meta: {
headerTitle: "Интерфейс",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "serverLabel",
accessorKey: "serverLabel",
header: ({ column }) => <DataGridSortHeader column={column} title="Роутер" />,
cell: ({ row }) => <span className="font-mono">{row.original.serverLabel}</span>,
meta: {
headerTitle: "Роутер",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "area",
accessorKey: "area",
header: ({ column }) => <DataGridSortHeader column={column} title="Область" />,
cell: ({ row }) => (
<span className="font-mono text-muted-foreground">{row.original.area}</span>
),
meta: {
headerTitle: "Область",
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[],
)
const table = useReactTable({
data: routes,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (routes.length === 0) {
return (
<EmptyState
icon={<RouteIcon className="size-4" />}
title="Нет OSPF-маршрутов"
className="border-0 py-10"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={routes.length}
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row hover:bg-muted/30" }}
tableLayout={{ dense: true }}
/>
)
}
export { OspfRoutesDataGrid, type OspfRoutesDataGridProps, routeTypeClass }
@@ -0,0 +1,221 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { Server } from "@/lib/data"
import { cn } from "@/lib/utils"
import { FormToggle } from "@/components/form-kit"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { ClockIcon, Trash2Icon } from "lucide-react"
export type SchedType = "ping" | "bandwidth" | "both"
export interface SchedRule {
id: string
srcId: string
tunnelId: string
type: SchedType
intervalMin: number
enabled: boolean
lastRun: string | null
nextRunMin: number | null
}
const TYPE_LABEL: Record<SchedType, string> = {
ping: "Ping",
bandwidth: "BW-тест",
both: "Ping + BW",
}
interface ProbesScheduleDataGridProps {
rules: SchedRule[]
serverOptions: Server[]
tunnelName: (srcId: string, tunnelId: string) => string | undefined
onToggleEnabled: (id: string, enabled: boolean) => void
onDelete: (id: string) => void
}
function ProbesScheduleDataGrid({
rules,
serverOptions,
tunnelName,
onToggleEnabled,
onDelete,
}: ProbesScheduleDataGridProps) {
const serverMap = useMemo(
() => new Map(serverOptions.map((s) => [s.id, s])),
[serverOptions],
)
const columns = useMemo<ColumnDef<SchedRule>[]>(
() => [
{
id: "enabled",
accessorKey: "enabled",
header: () => <span className="sr-only">Вкл</span>,
enableSorting: false,
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()}>
<FormToggle
checked={row.original.enabled}
onChange={(v) => onToggleEnabled(row.original.id, v)}
/>
</div>
),
size: 48,
meta: {
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "tunnel",
accessorKey: "tunnelId",
header: ({ column }) => <DataGridSortHeader column={column} title="Туннель" />,
cell: ({ row }) => (
<code className="font-mono text-xs truncate block">
{tunnelName(row.original.srcId, row.original.tunnelId) ?? row.original.tunnelId}
</code>
),
meta: {
headerTitle: "Туннель",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "server",
accessorKey: "srcId",
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground truncate block">
{serverMap.get(row.original.srcId)?.name ?? row.original.srcId}
</span>
),
meta: {
headerTitle: "Сервер",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "type",
accessorKey: "type",
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
cell: ({ row }) => (
<span
className={cn(
"text-[10px] px-1.5 py-0.5 rounded border font-medium w-fit",
row.original.type === "ping"
? "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20"
: row.original.type === "bandwidth"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
)}
>
{TYPE_LABEL[row.original.type]}
</span>
),
meta: {
headerTitle: "Тип",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "intervalMin",
accessorKey: "intervalMin",
header: ({ column }) => <DataGridSortHeader column={column} title="Интервал" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">каждые {row.original.intervalMin} мин</span>
),
meta: {
headerTitle: "Интервал",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "lastRun",
header: () => (
<span className="text-xs font-medium text-muted-foreground">Последний / следующий</span>
),
enableSorting: false,
cell: ({ row }) => {
const rule = row.original
return (
<div className="text-xs text-muted-foreground flex items-center gap-2 min-w-0">
{rule.lastRun && <span className="truncate">{rule.lastRun}</span>}
{rule.nextRunMin != null && rule.enabled && (
<span className="text-sky-600 dark:text-sky-400 shrink-0">
· через {rule.nextRunMin} мин
</span>
)}
</div>
)
},
meta: {
headerTitle: "Последний / следующий",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
enableSorting: false,
cell: ({ row }) => (
<button
type="button"
onClick={() => onDelete(row.original.id)}
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-red-500 hover:bg-red-500/10 transition-colors opacity-0 group-hover/row:opacity-100 focus-visible:opacity-100"
aria-label="Удалить правило"
>
<Trash2Icon className="size-3.5" />
</button>
),
size: 48,
meta: {
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[onDelete, onToggleEnabled, serverMap, tunnelName],
)
const table = useReactTable({
data: rules,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (rules.length === 0) {
return (
<EmptyState
icon={<ClockIcon className="size-4" />}
title="Нет правил расписания"
description="Добавьте правило ping или bandwidth-теста"
className="border-0 py-10"
/>
)
}
return <DataGridShell table={table} recordCount={rules.length} />
}
export { ProbesScheduleDataGrid, type ProbesScheduleDataGridProps }
@@ -0,0 +1,178 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { ClockIcon } from "lucide-react"
export interface SpeedProbeApiRow {
id: string
srcServerId: string
dstServerId: string
srcInterface: string
dstInterface: string
protocol: string
direction: string
durationSec: string
enabled: boolean
lastRunAt: string | null
lastTxAvgMbps: number | null
lastRxAvgMbps: number | null
lastStatus: string | null
lastError: string | null
}
interface ProbesSpeedProbesDataGridProps {
rows: SpeedProbeApiRow[]
serverName: (id: string) => string
}
function ProbesSpeedProbesDataGrid({ rows, serverName }: ProbesSpeedProbesDataGridProps) {
const columns = useMemo<ColumnDef<SpeedProbeApiRow>[]>(
() => [
{
id: "src",
header: ({ column }) => <DataGridSortHeader column={column} title="Источник" className="ml-1" />,
accessorFn: (row) => row.srcServerId,
cell: ({ row }) => {
const r = row.original
return (
<span className="truncate font-mono text-xs block">
{serverName(r.srcServerId)}
{r.srcInterface ? ` · ${r.srcInterface}` : ""}
</span>
)
},
meta: {
headerTitle: "Источник",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "dst",
accessorFn: (row) => row.dstServerId,
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
cell: ({ row }) => {
const r = row.original
return (
<span className="truncate font-mono text-xs block">
{serverName(r.dstServerId)}
{r.dstInterface ? ` · ${r.dstInterface}` : ""}
</span>
)
},
meta: {
headerTitle: "Назначение",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "protocol",
accessorKey: "protocol",
header: ({ column }) => <DataGridSortHeader column={column} title="Протокол" />,
cell: ({ row }) => <span className="text-xs">{row.original.protocol.toUpperCase()}</span>,
meta: {
headerTitle: "Протокол",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "durationSec",
accessorKey: "durationSec",
header: ({ column }) => <DataGridSortHeader column={column} title="Сек" />,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.durationSec}s</span>,
meta: {
headerTitle: "Сек",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "enabled",
accessorKey: "enabled",
header: ({ column }) => <DataGridSortHeader column={column} title="Вкл" />,
cell: ({ row }) => <span className="text-xs">{row.original.enabled ? "да" : "нет"}</span>,
meta: {
headerTitle: "Вкл",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "lastRunAt",
accessorKey: "lastRunAt",
header: ({ column }) => <DataGridSortHeader column={column} title="Последний запуск" />,
cell: ({ row }) => {
const r = row.original
return (
<span className="text-xs text-muted-foreground truncate block">
{r.lastRunAt ?? "—"}
{r.lastStatus === "done" && r.lastTxAvgMbps != null && (
<span className="text-emerald-600 dark:text-emerald-400 ml-1">
TX{r.lastTxAvgMbps.toFixed(1)} RX{(r.lastRxAvgMbps ?? 0).toFixed(1)} Mb/s
</span>
)}
{r.lastStatus === "error" && r.lastError && (
<span className="text-destructive ml-1 truncate">{r.lastError}</span>
)}
</span>
)
},
meta: {
headerTitle: "Последний запуск",
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[serverName],
)
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (rows.length === 0) {
return (
<EmptyState
icon={<ClockIcon className="size-4" />}
title="Нет записей speed-test"
description="Настраиваются через API PUT /api/uptime/speed-probes"
className="border-0 py-10"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={rows.length}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
}}
/>
)
}
export { ProbesSpeedProbesDataGrid, type ProbesSpeedProbesDataGridProps }
@@ -0,0 +1,328 @@
"use client"
import { useMemo, useState } from "react"
import {
type ColumnDef,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import {
AlertCircleIcon,
ChevronDownIcon,
ChevronRightIcon,
PencilIcon,
RouteIcon,
TrashIcon,
} from "lucide-react"
export interface RecursiveRouteEndpoint {
id: string
gateway: string
distance: number
scope: number | null
targetScope: number | null
checkGateway: string
country: string
disabled: boolean
}
export interface RecursiveRouteGroup {
id?: string
key: string
dstAddress: string
routingTable: string
comment: string
endpoints: RecursiveRouteEndpoint[]
}
const INFER_COUNTRIES = [
{ code: "RU", keys: ["MSK", "SPB", "RTK", "MTS", "VPSVILLE", "IHOR"] },
{ code: "SE", keys: ["SWE", "STO"] },
{ code: "FI", keys: ["HEL", "FIN"] },
{ code: "DE", keys: ["FRA", "GER", "DE"] },
{ code: "NL", keys: ["AMS", "NLD", "NL"] },
{ code: "SG", keys: ["SGP", "SIN", "SG"] },
{ code: "TR", keys: ["TUR", "TR"] },
{ code: "US", keys: ["USA", "US", "NYC", "LAX"] },
]
function inferCountry(name: string): string | null {
const upper = name.toUpperCase()
for (const c of INFER_COUNTRIES) {
if (c.keys.some((k) => upper.includes(k))) return c.code
}
return null
}
function RouteGroupExpandedDetail({ group }: { group: RecursiveRouteGroup }) {
const sorted = [...group.endpoints].sort((a, b) => a.distance - b.distance)
return (
<div className="flex flex-col gap-4 px-5 py-4 bg-muted/20">
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
<span className="text-muted-foreground">
Route: <span className="font-mono text-foreground">{group.dstAddress}</span>
</span>
<span className="text-muted-foreground">
Table:{" "}
<span className="font-mono text-foreground">{group.routingTable || "main"}</span>
</span>
{group.comment && <span className="text-muted-foreground italic">{group.comment}</span>}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5">
{sorted.map((ep, idx) => (
<div key={ep.id} className="rounded-lg border border-border bg-background px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
{(ep.country || inferCountry(ep.gateway)) && (
<Flag code={ep.country || inferCountry(ep.gateway) || ""} size={16} />
)}
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide">
Endpoint {idx + 1}
</span>
</div>
<span className="text-[11px] font-mono">distance: {ep.distance}</span>
</div>
<p className="mt-1.5 font-mono text-sm break-all leading-tight">{ep.gateway}</p>
<div className="mt-1.5 text-[11px] text-muted-foreground flex items-center gap-3">
<span>scope: {ep.scope ?? "—"}</span>
<span>t.scope: {ep.targetScope ?? "—"}</span>
<span>check: {ep.checkGateway || "—"}</span>
</div>
</div>
))}
</div>
</div>
)
}
interface RecursiveRoutesDataGridProps {
groups: RecursiveRouteGroup[]
expandedKey?: string | null
onExpandedChange?: (key: string | null) => void
onEdit: (group: RecursiveRouteGroup) => void
onDelete: (group: RecursiveRouteGroup) => void
}
function RecursiveRoutesDataGrid({
groups,
expandedKey,
onExpandedChange,
onEdit,
onDelete,
}: RecursiveRoutesDataGridProps) {
const [confirmDeleteKey, setConfirmDeleteKey] = useState<string | null>(null)
const expanded = useMemo(() => {
if (!expandedKey) return {}
const g = groups.find((x) => x.key === expandedKey)
return g ? { [(g.id ?? g.key)]: true } : {}
}, [expandedKey, groups])
const columns = useMemo<ColumnDef<RecursiveRouteGroup>[]>(
() => [
{
id: "route",
accessorKey: "dstAddress",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Route / Comment" className="ml-1" />
),
cell: ({ row }) => {
const g = row.original
const isExpanded = expandedKey === g.key
return (
<div className="flex items-start gap-2 min-w-0">
{isExpanded ? (
<ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
)}
<div className="min-w-0">
<p className="font-medium truncate">{g.dstAddress}</p>
<p className="text-xs font-mono text-muted-foreground">{g.comment || "—"}</p>
</div>
</div>
)
},
meta: {
headerTitle: "Route / Comment",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (group: RecursiveRouteGroup) => (
<RouteGroupExpandedDetail group={group} />
),
},
},
{
id: "gateways",
header: () => <span className="text-xs font-medium text-muted-foreground">Gateways</span>,
enableSorting: false,
cell: ({ row }) => {
const sorted = [...row.original.endpoints].sort((a, b) => a.distance - b.distance)
return (
<div className="flex flex-col gap-0.5">
{sorted.map((ep, idx) => {
const code = ep.country || inferCountry(ep.gateway)
return (
<div key={ep.id} className="flex items-center gap-1.5 text-[11px] font-mono">
<span
className={cn(
"size-1.5 rounded-full shrink-0",
idx === 0 ? "bg-emerald-500" : "bg-sky-500",
)}
/>
{code ? (
<Flag code={code} size={14} className="shrink-0" />
) : (
<span className="text-[10px] text-muted-foreground w-3.5 text-center shrink-0">
?
</span>
)}
<span className="font-semibold text-sky-600 dark:text-sky-400 truncate min-w-0">
{ep.gateway}
</span>
</div>
)
})}
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "epCount",
header: () => <span className="text-xs font-medium text-muted-foreground">EP</span>,
enableSorting: false,
cell: ({ row }) => (
<span className="text-xs tabular-nums">{row.original.endpoints.length}</span>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "priority",
header: () => <span className="text-xs font-medium text-muted-foreground">Priority</span>,
enableSorting: false,
cell: ({ row }) => {
const bestDistance = Math.min(...row.original.endpoints.map((ep) => ep.distance))
return <span className="font-mono text-xs">d{bestDistance}</span>
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "table",
accessorKey: "routingTable",
header: ({ column }) => <DataGridSortHeader column={column} title="Table" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">
{row.original.routingTable || "main"}
</span>
),
meta: {
headerTitle: "Table",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
enableSorting: false,
cell: ({ row }) => {
const g = row.original
const confirmDel = confirmDeleteKey === g.key
return (
<div
className="flex items-center gap-0.5 justify-end opacity-0 group-hover/row:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<Button
size="sm"
variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => onEdit(g)}
>
<PencilIcon className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className={cn(
"size-7 p-0 transition-colors",
confirmDel
? "text-destructive bg-destructive/10 hover:bg-destructive/20"
: "text-muted-foreground hover:text-destructive",
)}
onClick={() => {
if (!confirmDel) setConfirmDeleteKey(g.key)
else {
onDelete(g)
setConfirmDeleteKey(null)
}
}}
onBlur={() => setConfirmDeleteKey(null)}
>
{confirmDel ? (
<AlertCircleIcon className="size-3.5" />
) : (
<TrashIcon className="size-3.5" />
)}
</Button>
</div>
)
},
size: 80,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[confirmDeleteKey, expandedKey, onDelete, onEdit],
)
const table = useReactTable({
data: groups,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowId: (row) => row.id ?? row.key,
getRowCanExpand: () => true,
state: { expanded },
})
if (groups.length === 0) {
return (
<EmptyState
icon={<RouteIcon className="size-4" />}
title="Нет маршрутов"
className="border-0 py-10"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={groups.length}
onRowClick={(row) => {
onExpandedChange?.(expandedKey === row.key ? null : row.key)
}}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: cn("group/row cursor-pointer", expandedKey && "data-[state=selected]:bg-muted/30"),
}}
/>
)
}
export { RecursiveRoutesDataGrid, type RecursiveRoutesDataGridProps, inferCountry }
@@ -0,0 +1,259 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { CommRec } from "@/lib/route-optimizer-data"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { ArrowRightIcon, PinIcon, RefreshCwIcon } from "lucide-react"
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
return (
<span
className={cn(
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border",
color ?? "bg-muted text-muted-foreground border-border",
)}
>
{children}
</span>
)
}
function ProbChip({ prob, best }: { prob: number; best?: boolean }) {
return (
<Chip
color={
best
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-muted text-muted-foreground border-border"
}
>
{prob}%
</Chip>
)
}
interface RouteOptimizerCommRecsDataGridProps {
recs: CommRec[]
homeId: string
pinned: Set<string>
applied: Set<string>
applying: Set<string>
onPin: (key: string) => void
onApply: (community: string, homeId: string) => void
}
function RouteOptimizerCommRecsDataGrid({
recs,
homeId,
pinned,
applied,
applying,
onPin,
onApply,
}: RouteOptimizerCommRecsDataGridProps) {
const rows = useMemo(
() => recs.map((r, idx) => ({ ...r, _rowKey: `${homeId}::${r.community}::${idx}` })),
[homeId, recs],
)
const columns = useMemo<ColumnDef<(typeof rows)[number]>[]>(
() => [
{
id: "community",
accessorKey: "community",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Community" className="ml-1" />
),
cell: ({ row }) => (
<div>
<div className="font-mono text-xs font-medium">{row.original.community}</div>
<div className="text-[11px] text-muted-foreground">{row.original.communityName}</div>
</div>
),
meta: {
headerTitle: "Community",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "current",
header: () => (
<span className="text-[11px] font-medium text-muted-foreground">
Текущий (WAN JH Exit)
</span>
),
enableSorting: false,
cell: ({ row }) => {
const r = row.original
if (!r.current) return <span className="text-muted-foreground text-xs"></span>
return (
<div className="text-xs flex items-center gap-1 flex-wrap">
<span className="font-mono font-medium text-sky-600 dark:text-sky-400">
{r.current.wan}
</span>
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
<span>{r.current.jh}</span>
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">{r.current.exit}</span>
<span className="font-mono text-[10px] text-muted-foreground">
({r.current.gateway})
</span>
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "recommended",
header: () => <span className="text-[11px] font-medium text-muted-foreground">Рекомендуемый</span>,
enableSorting: false,
cell: ({ row }) => {
const r = row.original
const pinKey = `${homeId}::${r.community}`
const isPinned = pinned.has(pinKey)
if (!r.recommended) return <span className="text-muted-foreground text-xs"></span>
return (
<div
className={cn(
"text-xs flex items-center gap-1 flex-wrap",
r.shouldSwitch && !isPinned && "text-amber-600 dark:text-amber-400",
)}
>
<span className="font-mono font-medium">{r.recommended.wan}</span>
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
<span>{r.recommended.jh}</span>
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
<span>{r.recommended.exit}</span>
{r.shouldSwitch && !isPinned && (
<span className="ml-1 text-[10px] font-bold bg-amber-500/10 border border-amber-500/20 px-1.5 py-0.5 rounded">
+{(r.recommended.prob ?? 0) - (r.current?.prob ?? 0)}%
</span>
)}
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "probability",
header: () => (
<span className="text-[11px] font-medium text-muted-foreground block text-center">
P(тек / рек)
</span>
),
enableSorting: false,
cell: ({ row }) => {
const r = row.original
const pinKey = `${homeId}::${r.community}`
return (
<div className="flex items-center justify-center gap-1">
<ProbChip prob={r.current?.prob ?? 0} />
<span className="text-muted-foreground text-[10px]">/</span>
<ProbChip prob={r.recommended?.prob ?? 0} best={r.shouldSwitch && !pinned.has(pinKey)} />
</div>
)
},
meta: {
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "actions",
header: () => (
<span className="text-[11px] font-medium text-muted-foreground block text-right">
Действие
</span>
),
enableSorting: false,
cell: ({ row }) => {
const r = row.original
const pinKey = `${homeId}::${r.community}`
const isPinned = pinned.has(pinKey)
const isApplied = applied.has(pinKey)
const isApplying = applying.has(pinKey)
const canApply = r.shouldSwitch && !isPinned && !isApplied
return (
<div className="flex items-center justify-end gap-1.5" onClick={(e) => e.stopPropagation()}>
{isPinned && <PinIcon className="size-3 text-sky-500 fill-sky-500" />}
<Button
variant="outline"
size="sm"
className={cn("h-7 text-xs", isPinned && "text-sky-600 dark:text-sky-400 border-sky-500/30")}
onClick={() => onPin(pinKey)}
>
<PinIcon className={cn("size-3", isPinned && "fill-current")} />
{isPinned ? "Открепить" : "Закрепить"}
</Button>
{canApply && (
<Button
size="sm"
className="h-7 text-xs"
disabled={isApplying}
onClick={() => onApply(r.community, homeId)}
>
{isApplying ? (
<RefreshCwIcon className="size-3 animate-spin" />
) : (
"Применить"
)}
</Button>
)}
{isApplied && (
<span className="text-[10px] text-emerald-600 dark:text-emerald-400 font-medium">
применено
</span>
)}
</div>
)
},
meta: {
headerClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-right"),
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[applied, applying, homeId, onApply, onPin, pinned],
)
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row._rowKey,
})
return (
<DataGridShell
table={table}
recordCount={rows.length}
tableClassNames={{
headerRow: "border-b bg-muted/20",
bodyRow: cn(
"group/row hover:bg-muted/30",
"[&:has([data-comm-switch=true])]:bg-amber-500/5",
"[&:has([data-comm-applied=true])]:bg-emerald-500/5",
),
}}
tableLayout={{ dense: true }}
/>
)
}
export { RouteOptimizerCommRecsDataGrid, type RouteOptimizerCommRecsDataGridProps }
@@ -0,0 +1,294 @@
"use client"
import { useMemo, useState } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { FullRoute } from "@/lib/route-optimizer-data"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { ArrowRightIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
return (
<span
className={cn(
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border",
color ?? "bg-muted text-muted-foreground border-border",
)}
>
{children}
</span>
)
}
function ProbChip({ prob, best }: { prob: number; best?: boolean }) {
return (
<Chip
color={
best
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-muted text-muted-foreground border-border"
}
>
{prob}%
</Chip>
)
}
function ConfChip({ conf }: { conf: string }) {
const map: Record<string, string> = {
HIGH: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
MEDIUM: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
LOW: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
}
return <Chip color={map[conf] ?? map.LOW}>{conf}</Chip>
}
interface RouteOptimizerFullRoutesDataGridProps {
routes: FullRoute[]
bestId?: string
}
function RouteOptimizerFullRoutesDataGrid({
routes,
bestId,
}: RouteOptimizerFullRoutesDataGridProps) {
const [expanded, setExpanded] = useState(false)
const visibleRoutes = expanded ? routes : routes.slice(0, 5)
const indexed = useMemo(
() => visibleRoutes.map((r, index) => ({ ...r, _index: index })),
[visibleRoutes],
)
const columns = useMemo<ColumnDef<FullRoute & { _index: number }>[]>(
() => [
{
id: "index",
header: () => <span className="text-[11px] font-medium text-muted-foreground"># Маршрут</span>,
enableSorting: false,
cell: ({ row }) => {
const isBest = row.original.id === bestId || row.original._index === 0
return (
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-muted-foreground w-4">
{row.original._index + 1}
</span>
{isBest && (
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-1.5 py-0.5 rounded">
Лучший
</span>
)}
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
},
{
id: "wanJh",
header: () => <span className="text-[11px] font-medium text-muted-foreground">WAN JH</span>,
enableSorting: false,
cell: ({ row }) => {
const r = row.original
return (
<div className="flex items-center gap-1.5 text-xs">
<span className="font-mono font-semibold text-sky-600 dark:text-sky-400">{r.wan.name}</span>
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
<div>
<div className="font-medium">{r.jh.label}</div>
<div className="font-mono text-[10px] text-muted-foreground">
{r.hw.pingMs} мс · {r.hw.dlMbps} {r.hw.ulMbps}
</div>
</div>
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "jhExit",
header: () => <span className="text-[11px] font-medium text-muted-foreground">JH Exit</span>,
enableSorting: false,
cell: ({ row }) => {
const r = row.original
return (
<div className="text-xs">
<div className="flex items-center gap-1 font-medium">
<Flag code={r.exit.country} />
{r.exit.label}
<span className="text-[10px] text-muted-foreground">({r.exit.site})</span>
</div>
<div className="font-mono text-[10px] text-muted-foreground">
{r.je.pingMs} мс · {r.je.dlMbps} {r.je.ulMbps}
</div>
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "totalPing",
accessorFn: (row) => row.hw.pingMs + row.je.pingMs,
header: ({ column }) => (
<DataGridSortHeader column={column} title="Ping (итого)" className="mx-auto" />
),
cell: ({ row }) => {
const totalPing = row.original.hw.pingMs + row.original.je.pingMs
return (
<span
className={cn(
"font-mono text-xs text-center block",
totalPing < 40
? "text-emerald-600 dark:text-emerald-400"
: totalPing < 80
? "text-amber-600 dark:text-amber-400"
: "text-red-500",
)}
>
{totalPing} мс
</span>
)
},
meta: {
headerTitle: "Ping (итого)",
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "bw",
header: () => (
<span className="text-[11px] font-medium text-muted-foreground block text-center">
BW (мин)
</span>
),
enableSorting: false,
cell: ({ row }) => {
const r = row.original
const minDl = Math.min(r.hw.dlMbps, r.je.dlMbps)
const minUl = Math.min(r.hw.ulMbps, r.je.ulMbps)
return (
<div className="font-mono text-xs text-muted-foreground text-center">
<div>{minDl}</div>
<div>{minUl}</div>
</div>
)
},
meta: {
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "score",
accessorKey: "score",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Score" className="mx-auto" />
),
cell: ({ row }) => (
<span className="font-mono text-xs font-semibold text-center block">
{row.original.score}
</span>
),
meta: {
headerTitle: "Score",
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "prob",
accessorKey: "probabilityOptimal",
header: ({ column }) => (
<DataGridSortHeader column={column} title="P(opt)" className="mx-auto" />
),
cell: ({ row }) => {
const isBest = row.original.id === bestId || row.original._index === 0
return (
<div className="flex justify-center">
<ProbChip prob={row.original.probabilityOptimal} best={isBest} />
</div>
)
},
meta: {
headerTitle: "P(opt)",
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "confidence",
accessorKey: "confidence",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Conf." className="mx-auto" />
),
cell: ({ row }) => (
<div className="flex justify-center">
<ConfChip conf={row.original.confidence} />
</div>
),
meta: {
headerTitle: "Conf.",
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-center"),
},
},
],
[bestId],
)
const table = useReactTable({
data: indexed,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
return (
<div>
<DataGridShell
table={table}
recordCount={visibleRoutes.length}
tableClassNames={{
headerRow: "border-b bg-muted/20",
bodyRow: cn("group/row hover:bg-muted/30", "has-[[data-route-best=true]]:bg-emerald-500/5"),
}}
tableLayout={{ dense: true }}
/>
{routes.length > 5 && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="w-full py-2 text-xs text-muted-foreground hover:text-foreground transition-colors border-t flex items-center justify-center gap-1"
>
{expanded ? (
<>
<ChevronUpIcon className="size-3" />
Свернуть
</>
) : (
<>
<ChevronDownIcon className="size-3" />
Показать все {routes.length} комбинаций
</>
)}
</button>
)}
</div>
)
}
export { RouteOptimizerFullRoutesDataGrid, type RouteOptimizerFullRoutesDataGridProps }
@@ -0,0 +1,150 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { NetworkIcon } from "lucide-react"
export interface OspfPreviewInterfaceRow {
id: string
interface: string
currentCost: number
optimalCost: number
score: number
pingMs: number
dlMbps: number
ulMbps: number
}
interface RouteOptimizerOspfPreviewDataGridProps {
rows: OspfPreviewInterfaceRow[]
error?: string | null
loading?: boolean
}
function RouteOptimizerOspfPreviewDataGrid({
rows,
error,
loading,
}: RouteOptimizerOspfPreviewDataGridProps) {
const columns = useMemo<ColumnDef<OspfPreviewInterfaceRow>[]>(
() => [
{
id: "interface",
accessorKey: "interface",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Интерфейс" className="ml-1" />
),
cell: ({ row }) => <span className="font-mono">{row.original.interface}</span>,
meta: {
headerTitle: "Интерфейс",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "cost",
accessorKey: "currentCost",
header: ({ column }) => <DataGridSortHeader column={column} title="Cost" />,
cell: ({ row }) => {
const r = row.original
return (
<span className="font-mono">
<span className="text-sky-600 dark:text-sky-400">{r.currentCost}</span>
{" → "}
<span
className={
r.currentCost === r.optimalCost
? "text-emerald-600 dark:text-emerald-400"
: "text-amber-600 dark:text-amber-400"
}
>
{r.optimalCost}
</span>
</span>
)
},
meta: { headerTitle: "Cost", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "score",
accessorKey: "score",
header: ({ column }) => <DataGridSortHeader column={column} title="Score" />,
cell: ({ row }) => <span className="font-mono">{row.original.score}</span>,
meta: { headerTitle: "Score", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "pingMs",
accessorKey: "pingMs",
header: ({ column }) => <DataGridSortHeader column={column} title="Ping" />,
cell: ({ row }) => <span className="font-mono">{row.original.pingMs}ms</span>,
meta: { headerTitle: "Ping", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "speed",
header: () => <span className="text-xs font-medium text-muted-foreground">Speed (dl/ul)</span>,
enableSorting: false,
cell: ({ row }) => {
const r = row.original
return (
<span className="font-mono">
{r.dlMbps} / {r.ulMbps}
</span>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[],
)
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (error) {
return (
<p className="px-3 py-2 text-xs text-destructive">Ошибка preview: {error}</p>
)
}
if (!loading && rows.length === 0) {
return (
<EmptyState
icon={<NetworkIcon className="size-4" />}
title="Интерфейсы OSPF не найдены для выбранного сервера"
className="border-0 py-6 text-xs"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={rows.length}
isLoading={loading}
loadingMode="skeleton"
tableClassNames={{ headerRow: "border-b bg-muted/30", bodyRow: "group/row" }}
tableLayout={{ dense: true }}
/>
)
}
export { RouteOptimizerOspfPreviewDataGrid, type RouteOptimizerOspfPreviewDataGridProps }
@@ -0,0 +1,183 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { HomeRouter, JumpHost, WanJhLeg } from "@/lib/route-optimizer-data"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { WifiIcon } from "lucide-react"
function LossChip({ loss }: { loss: number }) {
if (loss === 0) return <span className="text-emerald-600 dark:text-emerald-400 text-[11px] font-mono">0%</span>
return (
<span className={cn("text-[11px] font-mono", loss > 1 ? "text-red-500" : "text-amber-500")}>
{loss}%
</span>
)
}
interface RouteOptimizerWanMatrixDataGridProps {
home: HomeRouter
legs: WanJhLeg[]
jumpHosts: JumpHost[]
}
function RouteOptimizerWanMatrixDataGrid({
home,
legs,
jumpHosts,
}: RouteOptimizerWanMatrixDataGridProps) {
const bestScore = legs.length ? Math.max(...legs.map((l) => l.score)) : 0
const columns = useMemo<ColumnDef<HomeRouter["wans"][number]>[]>(() => {
const base: ColumnDef<HomeRouter["wans"][number]>[] = [
{
id: "wan",
accessorKey: "name",
header: () => <span className="text-[11px] font-medium text-muted-foreground">WAN-аплинк</span>,
cell: ({ row }) => {
const wan = row.original
return (
<div className="flex items-center gap-2">
<WifiIcon className="size-3.5 text-muted-foreground shrink-0" />
<div>
<p className="font-mono text-xs font-semibold">{wan.name}</p>
<p className="text-[10px] text-muted-foreground">{wan.iface}</p>
</div>
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
},
{
id: "isp",
header: () => <span className="text-[11px] font-medium text-muted-foreground">ISP / IP</span>,
cell: ({ row }) => {
const wan = row.original
return (
<div>
<p className="text-xs font-medium">{wan.isp}</p>
<p className="font-mono text-[10px] text-muted-foreground">{wan.ip}</p>
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "bandwidth",
header: () => (
<span className="text-[11px] font-medium text-muted-foreground block text-right">
Макс. полоса
</span>
),
cell: ({ row }) => {
const wan = row.original
return (
<div className="text-right">
<p className="font-mono text-xs">{wan.maxDl}</p>
<p className="font-mono text-[10px] text-muted-foreground">{wan.maxUl} Мбит</p>
</div>
)
},
meta: {
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
},
},
]
const jhCols: ColumnDef<HomeRouter["wans"][number]>[] = jumpHosts.map((jh) => ({
id: `jh-${jh.id}`,
header: () => (
<div className="text-center">
<div className="text-[11px] font-medium text-muted-foreground">{jh.label}</div>
<div className="font-mono font-normal text-[10px] opacity-60 flex items-center justify-center gap-1">
<Flag code={jh.country} />
{jh.site} · {jh.ip}
</div>
</div>
),
cell: ({ row }) => {
const wan = row.original
const leg = legs.find((l) => l.wanId === wan.id && l.jhId === jh.id)
if (!leg) return <span className="text-center text-muted-foreground text-xs block"></span>
const isBest = leg.score === bestScore
return (
<div className={cn("text-center", isBest && "bg-emerald-500/5")}>
<div
className={cn(
"flex flex-col items-center gap-0.5 rounded-md px-2 py-1.5 transition-colors",
isBest ? "border border-emerald-500/20 bg-emerald-500/8" : "border border-transparent",
)}
>
{isBest && (
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 mb-0.5">
ЛУЧШИЙ
</span>
)}
<span
className={cn(
"font-mono text-xs font-semibold",
leg.pingMs < 10
? "text-emerald-600 dark:text-emerald-400"
: leg.pingMs < 25
? "text-foreground"
: "text-amber-600 dark:text-amber-400",
)}
>
{leg.pingMs} мс
</span>
<span className="text-[10px] text-muted-foreground font-mono">
{leg.dlMbps} {leg.ulMbps}
</span>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] font-mono text-foreground/70">score {leg.score}</span>
{leg.loss > 0 && <LossChip loss={leg.loss} />}
</div>
</div>
</div>
)
},
meta: {
headerClassName: cn(DATA_GRID_CELL_PAD, "min-w-[130px] text-center"),
cellClassName: cn(DATA_GRID_CELL_PAD, "min-w-[130px]"),
},
}))
const last = jhCols[jhCols.length - 1]
if (last?.meta) {
last.meta.headerClassName = cn(last.meta.headerClassName, DATA_GRID_CELL_PAD_LAST.replace("py-3", ""))
last.meta.cellClassName = DATA_GRID_CELL_PAD_LAST
}
return [...base, ...jhCols]
}, [bestScore, jumpHosts, legs])
const table = useReactTable({
data: home.wans,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={home.wans.length}
tableClassNames={{ headerRow: "border-b bg-muted/20", bodyRow: "group/row hover:bg-muted/30" }}
tableLayout={{ dense: true }}
/>
)
}
export { RouteOptimizerWanMatrixDataGrid, type RouteOptimizerWanMatrixDataGridProps }
@@ -0,0 +1,123 @@
"use client"
import type { Server } from "@/lib/data"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { RosBadge, rosVer } from "@/components/data-grids/servers-data-grid"
import { CheckCircleIcon, XCircleIcon, RefreshCwIcon } from "lucide-react"
const ROS_FEATURES = [
{ name: "WireGuard", minVer: 701, label: "7.1+", desc: "WireGuard VPN туннели" },
{ name: "Container", minVer: 704, label: "7.4+", desc: "Docker-совместимые контейнеры" },
{ name: "BFD", minVer: 705, label: "7.5+", desc: "Bidirectional Forwarding Detection" },
{ name: "Large Communities", minVer: 707, label: "7.7+", desc: "BGP Large Communities (RFC 8092)" },
{ name: "VXLAN", minVer: 710, label: "7.10+", desc: "VXLAN overlay туннели" },
{ name: "RPKI", minVer: 713, label: "7.13+", desc: "Route Origin Validation" },
{ name: "BGP Flowspec", minVer: 714, label: "7.14+", desc: "BGP Flow Spec (RFC 8955)" },
{ name: "IPv6 Firewall", minVer: 715, label: "7.15+", desc: "Расширенный IPv6 Firewall" },
{ name: "REST API v2", minVer: 716, label: "7.16+", desc: "Обновлённый REST API" },
{ name: "VRF Enhanced", minVer: 717, label: "7.17+", desc: "Расширенная поддержка VRF" },
]
interface ServerExpandedDetailProps {
server: Server
isLive: boolean
isPolling: boolean
onPoll: () => void
}
function ServerExpandedDetail({ server: s, isLive, isPolling, onPoll }: ServerExpandedDetailProps) {
const ver = rosVer(s.os)
return (
<div className="flex flex-col gap-5 px-5 py-5 bg-muted/20">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
{s.model && s.model !== "—" && (
<span className="text-muted-foreground">Модель: <span className="font-mono text-foreground">{s.model}</span></span>
)}
{s.uptime && (
<span className="text-muted-foreground">Uptime: <span className="font-mono text-foreground">{s.uptime}</span></span>
)}
{s.cpuLoad != null && (
<span className="text-muted-foreground">CPU: <span className={cn("font-mono font-semibold", s.cpuLoad > 80 ? "text-red-400" : s.cpuLoad > 50 ? "text-amber-400" : "text-emerald-400")}>{s.cpuLoad}%</span></span>
)}
{s.asn && (
<span className="text-muted-foreground">ASN: <span className="font-mono text-foreground">{s.asn}</span></span>
)}
{s.ipv6Address && (
<span className="text-muted-foreground">IPv6: <span className="font-mono text-sky-400">{s.ipv6Address}</span></span>
)}
{s.vrfNames?.map((v) => (
<span key={v} className="text-muted-foreground">VRF: <span className="font-mono text-foreground">{v}</span></span>
))}
{s.comment && <span className="text-muted-foreground italic">{s.comment}</span>}
{s.polledAt ? (
<span className="text-muted-foreground/50 text-[11px]">
Опрошен: {new Date(s.polledAt).toLocaleString("ru")}
</span>
) : (
<span className="text-amber-500/70 text-[11px]"> Ещё не опрашивался</span>
)}
</div>
{isLive && (
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 text-xs shrink-0"
disabled={isPolling}
onClick={(e) => { e.stopPropagation(); onPoll() }}
>
<RefreshCwIcon className={cn("size-3.5", isPolling && "animate-spin")} />
{isPolling ? "Опрос…" : "Опросить сейчас"}
</Button>
)}
</div>
<div>
<div className="flex items-center gap-3 mb-3">
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
Возможности RouterOS
</p>
<RosBadge os={s.os} />
<span className="text-[11px] text-muted-foreground">
{ver >= 715
? "✓ Актуальная версия — все ключевые фичи доступны"
: ver >= 710
? "⚠ Рекомендуется обновление до 7.15+"
: s.os !== "—"
? "✗ Устаревшая версия — требуется обновление"
: "Нет данных — нажмите «Опросить сейчас»"}
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
{ROS_FEATURES.map((f) => {
const ok = ver >= f.minVer
return (
<div
key={f.name}
className={cn(
"flex items-start gap-2 rounded-md border px-3 py-2.5 transition-colors",
ok ? "border-emerald-500/25 bg-emerald-500/5" : "border-border/40 bg-background/40 opacity-60",
)}
>
{ok
? <CheckCircleIcon className="size-3.5 text-emerald-500 shrink-0 mt-0.5" />
: <XCircleIcon className="size-3.5 text-muted-foreground/40 shrink-0 mt-0.5" />}
<div className="min-w-0">
<p className={cn("text-xs font-medium leading-tight truncate", ok ? "text-foreground" : "text-muted-foreground")}>
{f.name}
</p>
<p className="text-[10px] text-muted-foreground leading-tight mt-0.5">
{f.label} · {f.desc}
</p>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
export { ServerExpandedDetail }
+390
View File
@@ -0,0 +1,390 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { Server, ServerType } from "@/lib/data"
import { StatusBadge } from "@/components/status-badge"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { ServerExpandedDetail } from "@/components/data-grids/server-expanded-detail"
import {
ChevronRightIcon,
ChevronDownIcon,
HomeIcon,
ServerIcon,
NetworkIcon,
ShieldIcon,
WifiIcon,
MoreHorizontalIcon,
ExternalLinkIcon,
PencilIcon,
RefreshCwIcon,
PowerIcon,
Trash2Icon,
} from "lucide-react"
const TYPE_LABELS: Record<ServerType, string> = {
"jump-host": "JumpHost",
"exit-node": "Exit Node",
"home-router": "Home Router",
}
const TYPE_STYLES: Record<ServerType, string> = {
"jump-host": "bg-violet-500/10 text-violet-400 border-violet-500/20",
"exit-node": "bg-sky-500/10 text-sky-400 border-sky-500/20",
"home-router": "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
}
function rosVer(os: string): number {
const m = os.match(/(\d+)\.(\d+)/)
if (!m) return 0
return parseInt(m[1], 10) * 100 + parseInt(m[2], 10)
}
function TypeBadge({ type }: { type: ServerType }) {
const icon =
type === "jump-host" ? <ServerIcon className="size-3 mr-1" />
: type === "exit-node" ? <NetworkIcon className="size-3 mr-1" />
: <HomeIcon className="size-3 mr-1" />
return (
<span className={cn("inline-flex items-center text-xs font-medium border rounded px-2 py-0.5", TYPE_STYLES[type])}>
{icon}{TYPE_LABELS[type]}
</span>
)
}
function RosBadge({ os }: { os: string }) {
const v = rosVer(os)
const cls = v >= 715
? "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/20"
: v >= 710
? "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/20"
: "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/20"
return <span className={cn("text-xs font-mono border rounded px-2 py-0.5", cls)}>{os}</span>
}
interface ServerRowActionsProps {
server: Server
isLive: boolean
isPolling: boolean
onEdit: (server: Server) => void
onDelete: (id: string) => void
onPoll: (id: string) => void
onToggleStatus: (id: string) => void
}
function ServerRowActions({
server,
isLive,
isPolling,
onEdit,
onDelete,
onPoll,
onToggleStatus,
}: ServerRowActionsProps) {
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="outline"
size="icon"
className={cn(
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
"opacity-0 transition-[opacity,background-color,color,border-color]",
"group-hover/row:opacity-100 focus-visible:opacity-100",
"data-popup-open:opacity-100 data-popup-open:bg-muted",
"hover:bg-muted hover:text-foreground hover:border-border",
)}
onClick={(e) => e.stopPropagation()}
aria-label={`Действия: ${server.name}`}
>
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent align="end" side="bottom" className="w-52">
<DropdownMenuItem onClick={() => window.open(`https://${server.host}`, "_blank")}>
<ExternalLinkIcon className="size-4" />
Открыть WebFig
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(server)}>
<PencilIcon className="size-4" />
Редактировать
</DropdownMenuItem>
{isLive && (
<DropdownMenuItem onClick={() => onPoll(server.id)} disabled={isPolling}>
<RefreshCwIcon className={cn("size-4", isPolling && "animate-spin")} />
{isPolling ? "Опрос…" : "Опросить"}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onToggleStatus(server.id)}>
<PowerIcon className="size-4" />
{server.status === "offline" ? "Включить" : "Отключить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => onDelete(server.id)}>
<Trash2Icon className="size-4" />
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
interface ServersDataGridProps {
servers: Server[]
isLive: boolean
pollingIds: Set<string>
onPoll: (id: string) => void
onEdit: (server: Server) => void
onDelete: (id: string) => void
onToggleStatus: (id: string) => void
}
function ServersDataGrid({
servers,
isLive,
pollingIds,
onPoll,
onEdit,
onDelete,
onToggleStatus,
}: ServersDataGridProps) {
const columns = useMemo<ColumnDef<Server>[]>(
() => [
{
id: "name",
accessorKey: "name",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Имя / Хост" className="ml-1" />
),
cell: ({ row }) => {
const s = row.original
const expanded = row.getIsExpanded()
return (
<div className="flex items-start gap-2 min-w-0">
{expanded
? <ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
: <ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />}
<div className="min-w-0">
<p className="font-medium truncate">{s.name}</p>
<p className="text-xs font-mono text-muted-foreground">{s.host}</p>
{s.ipv6Address && (
<p className="text-[10px] font-mono text-sky-500/70 truncate max-w-[150px]" title={s.ipv6Address}>
{s.ipv6Address}
</p>
)}
</div>
</div>
)
},
meta: {
headerTitle: "Имя / Хост",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (row: Server) => (
<ServerExpandedDetail
server={row}
isLive={isLive}
isPolling={pollingIds.has(row.id)}
onPoll={() => onPoll(row.id)}
/>
),
},
},
{
id: "type",
accessorKey: "type",
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
cell: ({ row }) => <TypeBadge type={row.original.type} />,
meta: { headerTitle: "Тип", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "model",
accessorKey: "model",
header: ({ column }) => <DataGridSortHeader column={column} title="Модель" />,
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.model}</span>,
meta: { headerTitle: "Модель", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "os",
accessorKey: "os",
header: ({ column }) => <DataGridSortHeader column={column} title="RouterOS" />,
cell: ({ row }) => <RosBadge os={row.original.os} />,
meta: { headerTitle: "RouterOS", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "site",
accessorKey: "site",
header: ({ column }) => <DataGridSortHeader column={column} title="Площадка" />,
cell: ({ row }) => (
<div className="flex items-center gap-1.5">
<Flag code={row.original.country} />
<span className="font-medium text-sm">{row.original.site}</span>
</div>
),
meta: { headerTitle: "Площадка", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "wan",
header: () => (
<span className="text-xs font-medium text-muted-foreground">WAN / LAN</span>
),
enableSorting: false,
cell: ({ row }) => {
const s = row.original
if (s.type === "home-router" && s.wanUplinks?.length) {
return (
<div className="flex flex-col gap-0.5">
{s.wanUplinks.map((w) => (
<div key={w.id} className="flex items-center gap-1.5 text-[11px] font-mono">
<WifiIcon className="size-3 text-sky-400 shrink-0" />
<span className="font-semibold text-sky-600 dark:text-sky-400">{w.name}</span>
<span className="text-muted-foreground">{w.isp}</span>
<span className="text-muted-foreground">{w.maxDl}{w.maxUl}</span>
</div>
))}
{s.lanSubnet && (
<div className="text-[10px] font-mono text-muted-foreground mt-0.5">LAN {s.lanSubnet}</div>
)}
</div>
)
}
return (
<div className="flex flex-col gap-0.5">
{s.wireGuardIfaces && s.wireGuardIfaces.length > 0 && (
<div className="text-[11px] font-mono text-violet-500 dark:text-violet-400 flex items-center gap-1">
<ShieldIcon className="size-3" />
WG: {s.wireGuardIfaces.length} iface · {s.wireGuardIfaces.reduce((n, i) => n + i.peers.length, 0)} peers
</div>
)}
{s.rpkiEnabled && (
<div className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400">RPKI </div>
)}
{!s.wireGuardIfaces?.length && !s.rpkiEnabled && (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
)
},
meta: { headerTitle: "WAN / LAN", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "latency",
accessorKey: "latency",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Задержка" className="w-full justify-end" />
),
cell: ({ row }) => {
const s = row.original
return (
<span className={cn(
"font-mono text-sm block text-right tabular-nums",
s.latency == null ? "text-muted-foreground" : s.latency > 60 ? "text-[var(--status-degraded-fg)]" : "",
)}>
{s.latency == null ? "—" : `${s.latency} мс`}
</span>
)
},
meta: {
headerTitle: "Задержка",
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
},
},
{
id: "status",
accessorKey: "status",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => <StatusBadge status={row.original.status} />,
meta: { headerTitle: "Статус", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const s = row.original
return (
<ServerRowActions
server={s}
isLive={isLive}
isPolling={pollingIds.has(s.id)}
onEdit={onEdit}
onDelete={onDelete}
onPoll={onPoll}
onToggleStatus={onToggleStatus}
/>
)
},
enableSorting: false,
size: 56,
meta: {
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[isLive, onDelete, onEdit, onPoll, onToggleStatus, pollingIds],
)
const table = useReactTable({
data: servers,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowId: (row) => row.id,
getRowCanExpand: () => true,
})
if (servers.length === 0) {
return (
<EmptyState
icon={<ServerIcon className="size-4" />}
title="Нет серверов"
description="Добавьте первый MikroTik-сервер для мониторинга"
className="border-0 py-12"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={servers.length}
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
/>
)
}
export { ServersDataGrid, rosVer, RosBadge, TypeBadge }
@@ -0,0 +1,168 @@
"use client"
import { useMemo } from "react"
import type { Server } from "@/lib/data"
import { CompactDataGrid, type CompactDataGridColumn } from "@/components/data-grids/compact-data-grid"
import { cn } from "@/lib/utils"
type PermLevel = "none" | "read" | "write"
type Role = "admin" | "operator" | "viewer"
interface SectionPerm {
section: string
level: PermLevel
}
interface ServerPerm {
serverId: string
level: PermLevel
}
export interface AccessSummaryUser {
id: string
name: string
avatar: string
active: boolean
role: Role
sections: SectionPerm[]
servers: ServerPerm[]
}
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
return (
<div
className={cn(
"size-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0",
active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
)}
>
{avatar}
</div>
)
}
interface SettingsAccessSummaryDataGridProps {
users: AccessSummaryUser[]
servers: Server[]
allSectionsCount: number
}
function SettingsAccessSummaryDataGrid({
users,
servers,
allSectionsCount,
}: SettingsAccessSummaryDataGridProps) {
const columns = useMemo<CompactDataGridColumn<AccessSummaryUser>[]>(
() => [
{
id: "name",
header: "Пользователь",
accessorKey: "name",
cell: (u) => (
<div className="flex items-center gap-2">
<AvatarCircle avatar={u.avatar} active={u.active} />
<span className="text-sm font-medium">{u.name}</span>
</div>
),
},
{
id: "sections",
header: "Разделы",
enableSorting: false,
cell: (u) => {
const writeSections =
u.role === "admin"
? []
: u.sections.filter((s) => s.level === "write").map((s) => s.section)
const readSections =
u.role === "admin"
? []
: u.sections.filter((s) => s.level === "read").map((s) => s.section)
return (
<span className="text-xs text-muted-foreground">
{u.role === "admin" ? (
<span className="text-violet-600 dark:text-violet-400 font-medium">
Все ({allSectionsCount})
</span>
) : (
<span>{readSections.length + writeSections.length} из {allSectionsCount}</span>
)}
</span>
)
},
},
{
id: "servers",
header: "Серверы",
enableSorting: false,
cell: (u) => {
const accessServers =
u.role === "admin"
? servers
: servers.filter((s) => u.servers.find((p) => p.serverId === s.id && p.level !== "none"))
return (
<span className="text-xs text-muted-foreground">
{u.role === "admin" ? (
<span className="text-violet-600 dark:text-violet-400 font-medium">
Все ({servers.length})
</span>
) : (
<span>
{accessServers.length} из {servers.length}
</span>
)}
</span>
)
},
},
{
id: "write",
header: "Права записи",
enableSorting: false,
cell: (u) => {
const writeSections =
u.role === "admin"
? []
: u.sections.filter((s) => s.level === "write").map((s) => s.section)
return (
<div className="flex flex-wrap gap-1">
{u.role === "admin" ? (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20">
Полный доступ
</span>
) : writeSections.length === 0 ? (
<span className="text-[10px] text-muted-foreground">Только просмотр</span>
) : (
<>
{writeSections.slice(0, 3).map((s) => (
<span
key={s}
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20"
>
{s}
</span>
))}
{writeSections.length > 3 && (
<span className="text-[10px] text-muted-foreground">+{writeSections.length - 3}</span>
)}
</>
)}
</div>
)
},
},
],
[allSectionsCount, servers],
)
return (
<CompactDataGrid
data={users}
columns={columns}
compact
emptyTitle="Нет пользователей"
/>
)
}
export { SettingsAccessSummaryDataGrid, type SettingsAccessSummaryDataGridProps }
@@ -0,0 +1,28 @@
import type { DataGridProps } from "@/components/reui/data-grid/data-grid"
const DATA_GRID_CELL_PAD = "py-3"
const DATA_GRID_CELL_PAD_FIRST = "pl-5 py-3"
const DATA_GRID_CELL_PAD_LAST = "pr-4 py-3"
const DEFAULT_DATA_GRID_LAYOUT: NonNullable<DataGridProps<object>["tableLayout"]> = {
rowBorder: true,
headerBackground: true,
headerBorder: true,
columnsResizable: false,
}
const DEFAULT_DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<object>["tableClassNames"]> = {
headerRow: "border-b border-border",
bodyRow: "group/row",
}
const DATA_GRID_CONTAINER_CLASS = "rounded-none border-0"
export {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
DEFAULT_DATA_GRID_LAYOUT,
DEFAULT_DATA_GRID_CLASS_NAMES,
DATA_GRID_CONTAINER_CLASS,
}
@@ -0,0 +1,68 @@
"use client"
import type { ReactNode } from "react"
import type { Table } from "@tanstack/react-table"
import {
DataGrid,
DataGridContainer,
DataGridPagination,
DataGridTable,
type DataGridProps,
} from "@/components/reui/data-grid"
import {
DATA_GRID_CONTAINER_CLASS,
DEFAULT_DATA_GRID_CLASS_NAMES,
DEFAULT_DATA_GRID_LAYOUT,
} from "@/components/data-grids/shared/data-grid-layout"
interface DataGridShellProps<TData extends object> {
table: Table<TData>
recordCount: number
children?: ReactNode
pagination?: boolean
isLoading?: boolean
loadingMode?: DataGridProps<TData>["loadingMode"]
emptyMessage?: ReactNode
onRowClick?: DataGridProps<TData>["onRowClick"]
tableLayout?: DataGridProps<TData>["tableLayout"]
tableClassNames?: DataGridProps<TData>["tableClassNames"]
}
function DataGridShell<TData extends object>({
table,
recordCount,
children,
pagination = false,
isLoading,
loadingMode,
emptyMessage,
onRowClick,
tableLayout = DEFAULT_DATA_GRID_LAYOUT,
tableClassNames = DEFAULT_DATA_GRID_CLASS_NAMES,
}: DataGridShellProps<TData>) {
return (
<DataGrid
table={table}
recordCount={recordCount}
isLoading={isLoading}
loadingMode={loadingMode}
emptyMessage={emptyMessage}
onRowClick={onRowClick}
tableLayout={tableLayout}
tableClassNames={tableClassNames}
>
{children ?? (
<>
<DataGridContainer border={false} className={DATA_GRID_CONTAINER_CLASS}>
<DataGridTable />
</DataGridContainer>
{pagination && recordCount > 0 && (
<DataGridPagination className="px-5 pb-3" />
)}
</>
)}
</DataGrid>
)
}
export { DataGridShell, type DataGridShellProps }
@@ -0,0 +1,51 @@
"use client"
import type { Column } from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon } from "lucide-react"
interface DataGridSortHeaderProps<TData> {
column: Column<TData, unknown>
title: string
className?: string
}
function DataGridSortHeader<TData>({
column,
title,
className,
}: DataGridSortHeaderProps<TData>) {
const sorted = column.getIsSorted()
const canSort = column.getCanSort()
if (!canSort) {
return (
<span className={cn("text-xs font-medium text-muted-foreground", className)}>
{title}
</span>
)
}
return (
<button
type="button"
className={cn(
"inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground",
"hover:text-foreground transition-colors rounded-md -ml-1 px-1 py-0.5",
className,
)}
onClick={column.getToggleSortingHandler()}
>
{title}
{sorted === "asc" ? (
<ArrowUpIcon className="size-3 text-foreground" />
) : sorted === "desc" ? (
<ArrowDownIcon className="size-3 text-foreground" />
) : (
<ArrowUpDownIcon className="size-3 opacity-35" />
)}
</button>
)
}
export { DataGridSortHeader, type DataGridSortHeaderProps }
@@ -0,0 +1,498 @@
"use client"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import {
CompactDataGrid,
type CompactDataGridColumn,
type CompactDataGridProps,
} from "@/components/data-grids/compact-data-grid"
import type {
AlertEngineRuleDiagSnapshot,
PingProbeSnapshot,
ResourceServerSnapshot,
ServerRestPingSnapshot,
SpeedRunSnapshot,
TrafficServerSnapshot,
} from "@/lib/scheduler-run-snapshot"
type SnapshotRow = { id: string }
function withRowId<T extends { serverId?: number; probeId?: string; ruleId?: string; target?: string }>(
rows: T[],
idFn: (row: T, index: number) => string,
): (T & SnapshotRow)[] {
return rows.map((row, index) => ({ ...row, id: idFn(row, index) }))
}
function SnapshotOkBadge({
ok,
okLabel = "ok",
errLabel = "ошибка",
}: {
ok: boolean
okLabel?: string
errLabel?: string
}) {
return ok ? (
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400">
{okLabel}
</Badge>
) : (
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive">
{errLabel}
</Badge>
)
}
function SnapshotErrorCell({ error, className }: { error?: string; className?: string }) {
return (
<span
className={cn("text-destructive max-w-[220px] truncate block", className)}
title={error}
>
{error ?? "—"}
</span>
)
}
function SnapshotDataGrid<T extends SnapshotRow>(
props: Omit<CompactDataGridProps<T>, "compact">,
) {
return <CompactDataGrid {...props} compact />
}
function TrafficSnapshotGrid({ servers }: { servers: TrafficServerSnapshot[] }) {
const data = withRowId(servers, (s) => String(s.serverId))
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
{ id: "name", header: "Сервер", accessorKey: "name", cell: (r) => <span className="font-medium">{r.name}</span> },
{
id: "host",
header: "Хост",
accessorKey: "host",
cell: (r) => <span className="font-mono text-muted-foreground">{r.host}</span>,
},
{
id: "ok",
header: "Результат",
enableSorting: false,
cell: (r) => <SnapshotOkBadge ok={r.ok} />,
},
{
id: "interfaces",
header: "IF",
accessorKey: "interfaces",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => r.interfaces ?? "—",
},
{
id: "sumRxMbps",
header: "Σ RX",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => (r.sumRxMbps != null ? `${r.sumRxMbps} Мбит/с` : "—"),
},
{
id: "sumTxMbps",
header: "Σ TX",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => (r.sumTxMbps != null ? `${r.sumTxMbps} Мбит/с` : "—"),
},
{
id: "error",
header: "Ошибка",
enableSorting: false,
cell: (r) => <SnapshotErrorCell error={r.error} />,
},
]
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет сэмплов трафика" />
}
function ResourcesSnapshotGrid({ servers }: { servers: ResourceServerSnapshot[] }) {
const data = withRowId(servers, (s) => String(s.serverId))
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
{
id: "name",
header: "Сервер",
enableSorting: false,
cell: (r) => (
<div>
<span className="font-medium">{r.name}</span>
<span className="block font-mono text-[10px] text-muted-foreground">{r.host}</span>
</div>
),
},
{
id: "status",
header: "Статус",
enableSorting: false,
cell: (r) => (
<Badge
variant="outline"
className={cn(
"text-[10px]",
r.status === "online" && "border-emerald-500/40 text-emerald-700 dark:text-emerald-400",
r.status === "offline" && "border-destructive/50 text-destructive",
)}
>
{r.status}
</Badge>
),
},
{
id: "cpuLoadPct",
header: "CPU %",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => r.cpuLoadPct ?? "—",
},
{
id: "memory",
header: "Память",
enableSorting: false,
headerClassName: "text-right",
cellClassName: "text-right tabular-nums whitespace-nowrap",
cell: (r) =>
r.memUsedMb != null && r.memTotalMb != null ? `${r.memUsedMb} / ${r.memTotalMb} МБ` : "—",
},
{
id: "memUsedPct",
header: "% RAM",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => (r.memUsedPct != null ? `${r.memUsedPct}%` : "—"),
},
{
id: "disk",
header: "Диск своб.",
enableSorting: false,
headerClassName: "text-right",
cellClassName: "text-right tabular-nums whitespace-nowrap",
cell: (r) =>
r.diskFreeMb != null && r.diskTotalMb != null ? `${r.diskFreeMb} / ${r.diskTotalMb} МБ` : "—",
},
{
id: "uptimeSeconds",
header: "Uptime",
cellClassName: "tabular-nums",
cell: (r) => (r.uptimeSeconds != null ? fmtUptimeSec(r.uptimeSeconds) : "—"),
},
{
id: "board",
header: "Плата / ROS",
enableSorting: false,
cell: (r) => (
<div className="max-w-[140px]">
<span className="block truncate" title={r.boardName}>{r.boardName || "—"}</span>
<span className="block truncate text-muted-foreground font-mono text-[10px]" title={r.rosVersion}>
{r.rosVersion || ""}
</span>
</div>
),
},
{
id: "error",
header: "Ошибка",
enableSorting: false,
cell: (r) => <SnapshotErrorCell error={r.error} className="max-w-[160px]" />,
},
]
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет сэмплов ресурсов" />
}
function ServersRestPingSnapshotGrid({ servers }: { servers: ServerRestPingSnapshot[] }) {
const data = withRowId(servers, (s) => String(s.serverId))
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
{ id: "name", header: "Сервер", accessorKey: "name", cell: (r) => <span className="font-medium">{r.name}</span> },
{
id: "host",
header: "Хост",
accessorKey: "host",
cell: (r) => <span className="font-mono text-muted-foreground">{r.host}</span>,
},
{
id: "ok",
header: "Результат",
enableSorting: false,
cell: (r) => <SnapshotOkBadge ok={r.ok} errLabel="недоступен" />,
},
{
id: "latencyMs",
header: "RTT REST",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => (r.latencyMs != null ? `${r.latencyMs} мс` : "—"),
},
{
id: "error",
header: "Ошибка",
enableSorting: false,
cell: (r) => <SnapshotErrorCell error={r.error} />,
},
]
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет сэмплов REST ping" />
}
function PingSnapshotGrid({ probes }: { probes: PingProbeSnapshot[] }) {
const data = withRowId(probes, (p) => `${p.probeId}-${p.target}`)
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
{
id: "name",
header: "Проба",
enableSorting: false,
cell: (r) => (
<div>
<span className="font-medium">{r.name}</span>
<span className="block font-mono text-[10px] text-muted-foreground">{r.probeId}</span>
</div>
),
},
{
id: "target",
header: "Цель",
accessorKey: "target",
cell: (r) => <span className="font-mono">{r.target}</span>,
},
{ id: "srcServerName", header: "Источник", accessorKey: "srcServerName" },
{
id: "srcInterface",
header: "IF",
accessorKey: "srcInterface",
cell: (r) => <span className="font-mono text-muted-foreground">{r.srcInterface || "—"}</span>,
},
{
id: "rttMs",
header: "RTT",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => (r.rttMs != null ? `${r.rttMs} мс` : "—"),
},
{
id: "lossPct",
header: "Loss",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => `${r.lossPct}%`,
},
{
id: "status",
header: "Статус",
enableSorting: false,
cell: (r) => <Badge variant="outline" className="text-[10px]">{r.status}</Badge>,
},
{
id: "error",
header: "Ошибка",
enableSorting: false,
cell: (r) => <SnapshotErrorCell error={r.error} className="max-w-[180px]" />,
},
]
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет сэмплов ping" />
}
function SpeedSnapshotGrid({ runs }: { runs: SpeedRunSnapshot[] }) {
const data = withRowId(runs, (r) => r.probeId)
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
{
id: "probeId",
header: "Проба",
accessorKey: "probeId",
cell: (r) => <span className="font-mono">{r.probeId}</span>,
},
{
id: "route",
header: "Маршрут",
enableSorting: false,
cell: (r) => (
<span className="whitespace-nowrap">
{r.srcServerName} <span className="text-muted-foreground"></span> {r.dstServerName}
</span>
),
},
{
id: "interfaces",
header: "Интерфейсы",
enableSorting: false,
cell: (r) => (
<span className="font-mono text-[10px]">
<span className="block">{r.srcInterface || "—"}</span>
<span className="block text-muted-foreground">{r.dstInterface || "—"}</span>
</span>
),
},
{
id: "protocol",
header: "Протокол",
enableSorting: false,
cell: (r) => `${r.protocol} / ${r.direction} / ${r.durationSec}s`,
},
{
id: "txAvgMbps",
header: "TX",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => (r.txAvgMbps != null ? `${Number(r.txAvgMbps).toFixed(1)}` : "—"),
},
{
id: "rxAvgMbps",
header: "RX",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => (r.rxAvgMbps != null ? `${Number(r.rxAvgMbps).toFixed(1)}` : "—"),
},
{
id: "pingRttMs",
header: "Ping RTT",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => (r.pingRttMs != null ? `${r.pingRttMs} мс` : "—"),
},
{
id: "pingLossPct",
header: "Loss",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => (r.pingLossPct != null ? `${r.pingLossPct}%` : "—"),
},
{
id: "ok",
header: "Результат",
enableSorting: false,
cell: (r) => <SnapshotOkBadge ok={r.ok} />,
},
{
id: "error",
header: "Ошибка",
enableSorting: false,
cell: (r) => (
<div className="max-w-[200px]">
<span className="text-destructive block truncate" title={r.error}>{r.error ?? ""}</span>
{r.pingError ? (
<span className="text-[10px] text-amber-600 dark:text-amber-400 block truncate" title={r.pingError ?? ""}>
ping: {r.pingError}
</span>
) : null}
</div>
),
},
]
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет прогонов speed" />
}
function transitionRu(t: AlertEngineRuleDiagSnapshot["hitTransition"]): string {
switch (t) {
case "problem":
return "проблема"
case "recovery":
return "восстановление"
case "neutral":
return "нейтрально"
default:
return "—"
}
}
function blockedRu(b: AlertEngineRuleDiagSnapshot["blocked"]): string {
switch (b) {
case "no_hit":
return "условие не выполнено"
case "stability":
return "стабильность (confirmStabilitySec)"
case "cooldown":
return "cooldown"
case "no_telegram":
return "нет Telegram"
case "dedupe_positive":
return "дедуп восстановления"
case "in_group":
return "в группе (отдельно не шлём)"
default:
return "—"
}
}
function AlertEngineRuleDiagGrid({ ruleDiag }: { ruleDiag: AlertEngineRuleDiagSnapshot[] }) {
const data = withRowId(ruleDiag, (d) => d.ruleId)
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
{
id: "ruleId",
header: "ID правила",
accessorKey: "ruleId",
cell: (r) => (
<span className="font-mono max-w-[140px] truncate block" title={r.ruleId}>
{r.ruleId}
</span>
),
},
{
id: "evalHit",
header: "Сработало",
cell: (r) => (r.evalHit ? "Да" : "Нет"),
},
{
id: "hitTransition",
header: "Тип срабатывания",
cell: (r) => transitionRu(r.hitTransition),
},
{
id: "stabilityOk",
header: "Стабильность",
cell: (r) => (r.stabilityOk ? "Да" : "Нет"),
},
{
id: "cooldownOk",
header: "Кулдаун",
cell: (r) => (r.cooldownOk ? "Да" : "Нет"),
},
{
id: "telegramOk",
header: "Telegram",
cell: (r) => (r.telegramOk ? "Да" : "Нет"),
},
{
id: "hitMessage",
header: "Сообщение",
enableSorting: false,
cell: (r) => (
<span className="font-mono max-w-[280px] truncate text-muted-foreground block" title={r.hitMessage ?? ""}>
{r.hitMessage ?? "—"}
</span>
),
},
{
id: "blocked",
header: "Причина блока",
cell: (r) => <span className="text-muted-foreground">{blockedRu(r.blocked)}</span>,
},
]
return (
<SnapshotDataGrid
data={data}
columns={columns}
emptyTitle="Нет диагностики по правилам"
/>
)
}
function fmtUptimeSec(sec: number): string {
if (sec <= 0) return "—"
const d = Math.floor(sec / 86400)
const h = Math.floor((sec % 86400) / 3600)
const m = Math.floor((sec % 3600) / 60)
if (d > 0) return `${d}д ${h}ч`
if (h > 0) return `${h}ч ${m}м`
return `${m}м`
}
export {
SnapshotDataGrid,
TrafficSnapshotGrid,
ResourcesSnapshotGrid,
ServersRestPingSnapshotGrid,
PingSnapshotGrid,
SpeedSnapshotGrid,
AlertEngineRuleDiagGrid,
type SnapshotRow,
}
@@ -0,0 +1,234 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { Server } from "@/lib/data"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { FormToggle } from "@/components/form-kit"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { CableIcon, CopyIcon, EyeIcon, EyeOffIcon, Trash2Icon } from "lucide-react"
export interface SubUserRow {
id: string
login: string
password: string
description: string
jhServerIds: string[]
clientIp: string
active: boolean
lastSeen: string | null
}
interface SubusersDataGridProps {
subUsers: SubUserRow[]
servers: Server[]
revealedIds: Set<string>
onToggleReveal: (id: string) => void
onToggleActive: (id: string) => void
onRemove: (id: string) => void
}
function SubusersDataGrid({
subUsers,
servers,
revealedIds,
onToggleReveal,
onToggleActive,
onRemove,
}: SubusersDataGridProps) {
const columns = useMemo<ColumnDef<SubUserRow>[]>(
() => [
{
id: "login",
accessorKey: "login",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Логин / описание" className="ml-1" />
),
cell: ({ row }) => {
const su = row.original
return (
<div className="min-w-0">
<p className="text-xs font-mono font-medium truncate">{su.login}</p>
{su.description && (
<p className="text-[11px] text-muted-foreground truncate">{su.description}</p>
)}
{su.lastSeen && (
<p className="text-[10px] text-muted-foreground/50">{su.lastSeen}</p>
)}
</div>
)
},
meta: {
headerTitle: "Логин / описание",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "password",
header: () => <span className="text-xs font-medium text-muted-foreground">Пароль</span>,
enableSorting: false,
cell: ({ row }) => {
const su = row.original
const revealed = revealedIds.has(su.id)
return (
<div className="flex items-center gap-1 min-w-0" onClick={(e) => e.stopPropagation()}>
<span className="font-mono text-[11px] truncate flex-1">
{revealed ? su.password : "••••••••••••"}
</span>
<button
type="button"
onClick={() => onToggleReveal(su.id)}
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors"
>
{revealed ? <EyeOffIcon className="size-3" /> : <EyeIcon className="size-3" />}
</button>
<button
type="button"
onClick={() => navigator.clipboard.writeText(su.password).catch(() => {})}
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors"
>
<CopyIcon className="size-3" />
</button>
</div>
)
},
meta: {
headerTitle: "Пароль",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "jhServers",
header: () => <span className="text-xs font-medium text-muted-foreground">JH-серверы</span>,
enableSorting: false,
cell: ({ row }) => {
const jhs = servers.filter((s) => row.original.jhServerIds.includes(s.id))
return (
<div className="flex flex-wrap gap-1 min-w-0">
{jhs.length === 0 ? (
<span className="text-[11px] text-muted-foreground/40"></span>
) : (
jhs.map((jh) => (
<span
key={jh.id}
className="inline-flex items-center gap-1 text-[10px] font-medium bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20 rounded px-1 py-0.5"
>
<Flag code={jh.country} size={10} />
{jh.name.split("-").slice(-1)[0]}
</span>
))
)}
</div>
)
},
meta: {
headerTitle: "JH-серверы",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "clientIp",
accessorKey: "clientIp",
header: ({ column }) => <DataGridSortHeader column={column} title="IP-клиента" />,
cell: ({ row }) => (
<span className="font-mono text-[11px] text-muted-foreground truncate">
{row.original.clientIp || "—"}
</span>
),
meta: {
headerTitle: "IP-клиента",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "active",
accessorKey: "active",
header: () => <span className="sr-only">Активен</span>,
enableSorting: false,
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()}>
<FormToggle
checked={row.original.active}
onChange={() => onToggleActive(row.original.id)}
/>
</div>
),
size: 48,
meta: {
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "actions",
header: () => <span className="sr-only">Удалить</span>,
enableSorting: false,
cell: ({ row }) => (
<button
type="button"
onClick={() => onRemove(row.original.id)}
className="text-muted-foreground/50 hover:text-destructive transition-colors opacity-0 group-hover/row:opacity-100 focus-visible:opacity-100"
aria-label="Удалить GRE-клиента"
>
<Trash2Icon className="size-3.5" />
</button>
),
size: 40,
meta: {
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[onRemove, onToggleActive, onToggleReveal, revealedIds, servers],
)
const table = useReactTable({
data: subUsers,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (subUsers.length === 0) {
return (
<EmptyState
icon={<CableIcon className="size-4" />}
title="Нет GRE-клиентов"
description="Добавьте учётки для подключения устройств"
className="border-0 py-12"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={subUsers.length}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
}}
/>
)
}
export { SubusersDataGrid, type SubusersDataGridProps }
@@ -0,0 +1,385 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { Server } from "@/lib/data"
import { Flag } from "@/components/flag"
import { Sparkline } from "@/components/sparkline"
import { StatusDot } from "@/components/status-dot"
import { cn } from "@/lib/utils"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import {
AlertCircleIcon,
ClockIcon,
CpuIcon,
HardDriveIcon,
ServerIcon,
SearchIcon,
ThermometerIcon,
} from "lucide-react"
export interface UptimeResourceRow {
serverId: string
server: Server
hasData: boolean
cpu: number
cpuHistory: number[]
ramUsed: number
ramTotal: number
ramPct: number
hddUsed: number
hddTotal: number
hddPct: number
uptimeSeconds: number
boardName: string
temp?: number
}
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
return (
<span className={cn(
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
type === "home-router"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: type === "jump-host"
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
)}>
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
</span>
)
}
function fmtMB(mb: number): string {
if (mb >= 1024) return `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ`
return `${mb.toFixed(1)} МБ`
}
function fmtUptime(sec: number): string {
const d = Math.floor(sec / 86400)
const h = Math.floor((sec % 86400) / 3600)
const m = Math.floor((sec % 3600) / 60)
if (d > 0) return `${d}д ${h}ч`
if (h > 0) return `${h}ч ${m}м`
return `${m}м`
}
function resPctColor(pct: number, warn = 70, crit = 85): string {
if (pct >= crit) return "text-red-600 dark:text-red-400"
if (pct >= warn) return "text-amber-600 dark:text-amber-400"
return "text-emerald-600 dark:text-emerald-400"
}
function resBarColor(pct: number, warn = 70, crit = 85): string {
if (pct >= crit) return "bg-red-500"
if (pct >= warn) return "bg-amber-500"
return "bg-emerald-500"
}
function MiniBar({
pct,
warn = 70,
crit = 85,
className,
}: {
pct: number
warn?: number
crit?: number
className?: string
}) {
return (
<div className={cn("h-1.5 rounded-full bg-muted overflow-hidden", className)}>
<div
className={cn("h-full rounded-full transition-all duration-700", resBarColor(pct, warn, crit))}
style={{ width: `${Math.min(100, Math.max(0, pct))}%` }}
/>
</div>
)
}
interface UptimeResourcesDataGridProps {
rows: UptimeResourceRow[]
}
function UptimeResourcesDataGrid({ rows }: UptimeResourcesDataGridProps) {
const columns = useMemo<ColumnDef<UptimeResourceRow>[]>(
() => [
{
id: "name",
accessorFn: (row) => row.server.name,
header: ({ column }) => (
<DataGridSortHeader column={column} title="Сервер" className="ml-1" />
),
cell: ({ row }) => {
const r = row.original
const srv = r.server
const offline = srv.status !== "online"
const noMetrics = offline || !r.hasData
const isCrit =
!noMetrics &&
(r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)
return (
<div className="flex items-center gap-2 flex-wrap">
{isCrit && <AlertCircleIcon className="size-3.5 text-red-500 shrink-0" />}
{!isCrit && <StatusDot status={srv.status} pulse={!offline} />}
<Flag code={srv.country} size={16} />
<span className="font-mono font-semibold">{srv.name}</span>
<TypeChip type={srv.type} />
<span className="text-xs text-muted-foreground hidden xl:inline">{srv.site}</span>
{!offline && !r.hasData && (
<span className="text-[10px] rounded border border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400 px-1.5 py-0.5">
нет данных
</span>
)}
</div>
)
},
meta: {
headerTitle: "Сервер",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "board",
accessorKey: "boardName",
header: () => (
<span className="text-xs font-medium text-muted-foreground hidden md:inline">
Модель · ROS
</span>
),
enableSorting: false,
cell: ({ row }) => {
const r = row.original
return (
<div className="hidden md:flex flex-col leading-tight">
<span className="font-mono text-xs text-muted-foreground">
{r.hasData ? r.boardName : "—"}
</span>
<span className="text-[10px] text-muted-foreground/50">{r.server.os}</span>
</div>
)
},
meta: { headerClassName: "hidden md:table-cell", cellClassName: "hidden md:table-cell px-4 py-3" },
},
{
id: "cpu",
accessorKey: "cpu",
header: ({ column }) => (
<span className="inline-flex items-center gap-1.5">
<CpuIcon className="size-3.5 text-muted-foreground" />
<DataGridSortHeader column={column} title="CPU" />
</span>
),
cell: ({ row }) => {
const r = row.original
const offline = r.server.status !== "online"
const noMetrics = offline || !r.hasData
if (noMetrics) {
return (
<span className="text-xs text-muted-foreground/30">
{offline ? "—" : "нет опроса"}
</span>
)
}
const cpuColor =
r.cpu >= 85 ? "hsl(0 84% 60%)" : r.cpu >= 70 ? "hsl(38 92% 50%)" : "hsl(142 76% 36%)"
return (
<div className="flex flex-col gap-1.5 min-w-[140px]">
<div className="flex items-center gap-2">
<span className={cn("font-mono text-sm font-semibold tabular-nums w-10 shrink-0", resPctColor(r.cpu))}>
{r.cpu}%
</span>
<MiniBar pct={r.cpu} className="flex-1" />
</div>
<Sparkline data={r.cpuHistory} width={120} height={18} color={cpuColor} filled />
</div>
)
},
meta: {
headerTitle: "CPU",
headerClassName: cn(DATA_GRID_CELL_PAD, "min-w-[160px]"),
cellClassName: cn(DATA_GRID_CELL_PAD, "min-w-[160px]"),
},
},
{
id: "ram",
accessorKey: "ramPct",
header: ({ column }) => (
<span className="inline-flex items-center gap-1.5">
<HardDriveIcon className="size-3.5 text-muted-foreground" />
<DataGridSortHeader column={column} title="RAM" />
</span>
),
cell: ({ row }) => {
const r = row.original
const offline = r.server.status !== "online"
const noMetrics = offline || !r.hasData
if (noMetrics) {
return <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
}
return (
<div className="flex flex-col gap-1.5 min-w-[155px]">
<div className="flex items-center justify-between text-xs">
<span className={cn("font-mono font-semibold", resPctColor(r.ramPct))}>{r.ramPct}%</span>
<span className="text-muted-foreground/60 font-mono text-[10px]">
{fmtMB(r.ramUsed)}/{fmtMB(r.ramTotal)}
</span>
</div>
<MiniBar pct={r.ramPct} />
</div>
)
},
meta: {
headerTitle: "RAM",
headerClassName: cn(DATA_GRID_CELL_PAD, "min-w-[175px]"),
cellClassName: cn(DATA_GRID_CELL_PAD, "min-w-[175px]"),
},
},
{
id: "hdd",
accessorKey: "hddPct",
header: ({ column }) => (
<span className="inline-flex items-center gap-1.5">
<HardDriveIcon className="size-3.5 text-muted-foreground" />
<DataGridSortHeader column={column} title="Диск" />
</span>
),
cell: ({ row }) => {
const r = row.original
const offline = r.server.status !== "online"
const noMetrics = offline || !r.hasData
if (noMetrics) {
return <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
}
return (
<div className="flex flex-col gap-1.5 min-w-[155px]">
<div className="flex items-center justify-between text-xs">
<span className={cn("font-mono font-semibold", resPctColor(r.hddPct))}>{r.hddPct}%</span>
<span className="text-muted-foreground/60 font-mono text-[10px]">
{fmtMB(r.hddUsed)}/{fmtMB(r.hddTotal)}
</span>
</div>
<MiniBar pct={r.hddPct} />
</div>
)
},
meta: {
headerTitle: "Диск",
headerClassName: cn(DATA_GRID_CELL_PAD, "min-w-[175px]"),
cellClassName: cn(DATA_GRID_CELL_PAD, "min-w-[175px]"),
},
},
{
id: "uptime",
accessorKey: "uptimeSeconds",
header: ({ column }) => (
<span className="inline-flex items-center gap-1.5">
<ClockIcon className="size-3.5 text-muted-foreground" />
<DataGridSortHeader column={column} title="Uptime" />
</span>
),
cell: ({ row }) => {
const r = row.original
const offline = r.server.status !== "online"
const noMetrics = offline || !r.hasData
return (
<span className="font-mono text-xs text-muted-foreground">
{noMetrics ? "—" : fmtUptime(r.uptimeSeconds)}
</span>
)
},
meta: {
headerTitle: "Uptime",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "temp",
accessorKey: "temp",
header: ({ column }) => (
<span className="inline-flex items-center gap-1.5">
<ThermometerIcon className="size-3.5 text-muted-foreground" />
<DataGridSortHeader column={column} title="°C" />
</span>
),
cell: ({ row }) => {
const r = row.original
const offline = r.server.status !== "online"
const noMetrics = offline || !r.hasData
if (r.temp !== undefined && !noMetrics) {
return (
<span
className={cn(
"font-mono text-sm font-semibold tabular-nums",
r.temp >= 70
? "text-red-600 dark:text-red-400"
: r.temp >= 55
? "text-amber-600 dark:text-amber-400"
: "text-muted-foreground",
)}
>
{r.temp}°C
</span>
)
}
return <span className="text-muted-foreground/30 text-xs"></span>
},
meta: {
headerTitle: "°C",
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[],
)
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.serverId,
})
if (rows.length === 0) {
return (
<EmptyState
icon={<SearchIcon className="size-4" />}
title="Ничего не найдено"
className="border-0 py-12"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={rows.length}
tableClassNames={{
headerRow: "border-b border-border bg-muted/30",
bodyRow: cn(
"group/row hover:bg-muted/30",
"[&:has([data-resource-offline=true])]:opacity-50",
"[&:has([data-resource-crit=true])]:bg-red-500/3",
),
}}
/>
)
}
export { UptimeResourcesDataGrid, type UptimeResourcesDataGridProps }
@@ -0,0 +1,242 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { Server } from "@/lib/data"
import { Flag } from "@/components/flag"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { cn } from "@/lib/utils"
import { ArrowRightIcon, RefreshCwIcon } from "lucide-react"
export interface SpeedTestRunRow {
id: string
startedAt: number
srcServerId: string
dstServerId: string
srcInterface?: string
dstInterface?: string
protocol: "tcp" | "udp"
direction: "transmit" | "receive" | "both"
durationSec: number
txAvgMbps: number
rxAvgMbps: number
status: "running" | "done" | "error"
afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null
srcInterfaceAddress?: string | null
dstInterfaceAddress?: string | null
}
interface UptimeSpeedHistoryDataGridProps {
runs: SpeedTestRunRow[]
servers: Server[]
}
function UptimeSpeedHistoryDataGrid({ runs, servers }: UptimeSpeedHistoryDataGridProps) {
const columns = useMemo<ColumnDef<SpeedTestRunRow>[]>(
() => [
{
id: "startedAt",
accessorKey: "startedAt",
header: ({ column }) => <DataGridSortHeader column={column} title="Время" className="ml-1" />,
cell: ({ row }) => (
<span className="text-muted-foreground whitespace-nowrap tabular-nums font-mono text-xs">
{new Date(row.original.startedAt).toLocaleString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
day: "2-digit",
month: "2-digit",
})}
</span>
),
meta: {
headerTitle: "Время",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "route",
accessorFn: (row) => `${row.srcServerId}-${row.dstServerId}`,
header: ({ column }) => <DataGridSortHeader column={column} title="Маршрут" />,
cell: ({ row }) => {
const run = row.original
const src = servers.find((s) => s.id === run.srcServerId)
const dst = servers.find((s) => s.id === run.dstServerId)
return (
<div className="font-mono whitespace-nowrap text-xs">
<div className="flex items-center gap-1.5">
<Flag code={src?.country ?? "UN"} size={13} />
<span>{src?.name ?? run.srcServerId}</span>
<ArrowRightIcon className="size-3 text-muted-foreground" />
<Flag code={dst?.country ?? "UN"} size={13} />
<span>{dst?.name ?? run.dstServerId}</span>
</div>
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
{run.srcInterfaceAddress && run.dstInterfaceAddress
? `${run.srcInterfaceAddress}${run.dstInterfaceAddress}`
: "внутренние IP: auto/не указаны"}
</div>
</div>
)
},
meta: { headerTitle: "Маршрут", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "params",
accessorFn: (row) => `${row.protocol}-${row.direction}-${row.durationSec}`,
header: ({ column }) => <DataGridSortHeader column={column} title="Параметры" />,
cell: ({ row }) => {
const run = row.original
return (
<div className="flex items-center gap-1 text-muted-foreground whitespace-nowrap text-xs">
<span className="inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold bg-muted/60 border-border/60">
{run.protocol.toUpperCase()}
</span>
<span className="text-muted-foreground/60">·</span>
<span>{run.direction}</span>
<span className="text-muted-foreground/60">·</span>
<span>{run.durationSec}s</span>
</div>
)
},
meta: { headerTitle: "Параметры", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "status",
accessorKey: "status",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => {
const status = row.original.status
if (status === "running") {
return (
<span className="inline-flex items-center gap-1 text-[var(--status-degraded-fg)] text-xs">
<RefreshCwIcon className="size-3 animate-spin" />
running
</span>
)
}
if (status === "error") {
return <span className="text-[var(--status-offline-fg)] text-xs">error</span>
}
return <span className="text-[var(--status-online-fg)] text-xs">done</span>
},
meta: { headerTitle: "Статус", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "txAvgMbps",
accessorKey: "txAvgMbps",
header: ({ column }) => <DataGridSortHeader column={column} title="TX avg" />,
cell: ({ row }) => {
const run = row.original
const maxVal = Math.max(run.txAvgMbps, run.rxAvgMbps, 1)
return (
<div className="flex items-center gap-2 min-w-[120px]">
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-[var(--chart-tx)]"
style={{ width: `${(run.txAvgMbps / maxVal) * 100}%` }}
/>
</div>
<span className="font-mono tabular-nums text-[var(--chart-tx)] font-medium whitespace-nowrap text-xs">
{run.txAvgMbps} Мбит/с
</span>
</div>
)
},
meta: { headerTitle: "TX avg", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "rxAvgMbps",
accessorKey: "rxAvgMbps",
header: ({ column }) => <DataGridSortHeader column={column} title="RX avg" />,
cell: ({ row }) => {
const run = row.original
const maxVal = Math.max(run.txAvgMbps, run.rxAvgMbps, 1)
return (
<div className="flex items-center gap-2 min-w-[120px]">
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-[var(--chart-rx)]"
style={{ width: `${(run.rxAvgMbps / maxVal) * 100}%` }}
/>
</div>
<span className="font-mono tabular-nums text-[var(--chart-rx)] font-medium whitespace-nowrap text-xs">
{run.rxAvgMbps} Мбит/с
</span>
</div>
)
},
meta: { headerTitle: "RX avg", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "afterBtPing",
accessorFn: (row) => row.afterBtPing?.rttMs ?? -1,
header: ({ column }) => <DataGridSortHeader column={column} title="Ping после BT" />,
cell: ({ row }) => {
const run = row.original
if (run.status !== "done") return <span className="text-xs"></span>
if (run.afterBtPing?.error) {
return (
<span className="text-[var(--status-offline-fg)] text-xs font-mono" title={run.afterBtPing.error}>
ошибка
</span>
)
}
if (run.afterBtPing?.rttMs != null) {
return (
<span className="font-mono tabular-nums whitespace-nowrap text-xs text-violet-600 dark:text-violet-400">
{run.afterBtPing.rttMs} мс
{run.afterBtPing.lossPct != null && run.afterBtPing.lossPct > 0 && (
<span className="text-amber-600 dark:text-amber-400"> · {run.afterBtPing.lossPct}%</span>
)}
</span>
)
}
return (
<span className="font-mono tabular-nums whitespace-nowrap text-xs text-amber-600 dark:text-amber-400">
timeout
{run.afterBtPing?.lossPct != null && <span> · {run.afterBtPing.lossPct}%</span>}
</span>
)
},
meta: {
headerTitle: "Ping после BT",
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[servers],
)
const table = useReactTable({
data: runs,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
initialState: { sorting: [{ id: "startedAt", desc: true }] },
})
return (
<DataGridShell
table={table}
recordCount={runs.length}
tableClassNames={{ bodyRow: cn("group/row text-xs") }}
/>
)
}
export { UptimeSpeedHistoryDataGrid, type UptimeSpeedHistoryDataGridProps }
+289
View File
@@ -0,0 +1,289 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { Server, VxlanTunnel } from "@/lib/data"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import {
CodeXmlIcon,
MoreHorizontalIcon,
NetworkIcon,
PencilIcon,
PowerIcon,
Trash2Icon,
} from "lucide-react"
interface VxlanDataGridProps {
tunnels: VxlanTunnel[]
servers: Server[]
onExport: (tunnel: VxlanTunnel) => void
}
function VxlanDataGrid({ tunnels, servers, onExport }: VxlanDataGridProps) {
const serverMap = useMemo(
() => new Map(servers.map((s) => [s.id, s])),
[servers],
)
const columns = useMemo<ColumnDef<VxlanTunnel>[]>(
() => [
{
id: "name",
accessorKey: "name",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Имя / VTEP" className="ml-1" />
),
cell: ({ row }) => {
const t = row.original
return (
<div className="min-w-0 flex items-start gap-2">
<span
className={cn(
"size-2 rounded-full shrink-0 mt-1.5",
t.status === "up" ? "bg-emerald-500" : "bg-red-500",
)}
/>
<div className="min-w-0">
<p className="font-mono font-medium text-sm truncate">{t.name}</p>
<p className="text-[11px] text-muted-foreground font-mono">VTEP: {t.vtepIp}</p>
</div>
</div>
)
},
meta: {
headerTitle: "Имя / VTEP",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "server",
accessorKey: "serverId",
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
cell: ({ row }) => {
const srv = serverMap.get(row.original.serverId)
if (!srv) return <span className="text-muted-foreground"></span>
return (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
<Flag code={srv.country} size={12} />
<span className="font-mono truncate">{srv.name}</span>
</div>
)
},
meta: {
headerTitle: "Сервер",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "vni",
accessorKey: "vni",
header: ({ column }) => <DataGridSortHeader column={column} title="VNI" />,
cell: ({ row }) => <span className="font-mono text-sm">{row.original.vni}</span>,
meta: {
headerTitle: "VNI",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "dstPort",
accessorKey: "dstPort",
header: ({ column }) => <DataGridSortHeader column={column} title="Port" />,
cell: ({ row }) => <span className="font-mono text-sm">{row.original.dstPort}</span>,
meta: {
headerTitle: "Port",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "remoteVteps",
header: () => (
<span className="text-xs font-medium text-muted-foreground">Remote</span>
),
enableSorting: false,
cell: ({ row }) => (
<span className="font-mono text-sm text-center block">{row.original.remoteVteps.length}</span>
),
meta: {
headerTitle: "Remote",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "arpProxy",
accessorKey: "arpProxy",
header: () => <span className="sr-only">ARP</span>,
enableSorting: false,
cell: ({ row }) => (
<span
className={cn(
"text-[10px] font-mono px-1.5 py-0.5 rounded",
row.original.arpProxy
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground",
)}
>
ARP {row.original.arpProxy ? "✓" : "✗"}
</span>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "macLearning",
accessorKey: "macLearning",
header: () => <span className="sr-only">MAC</span>,
enableSorting: false,
cell: ({ row }) => (
<span
className={cn(
"text-[10px] font-mono px-1.5 py-0.5 rounded",
row.original.macLearning
? "bg-sky-500/10 text-sky-600 dark:text-sky-400"
: "bg-muted text-muted-foreground",
)}
>
MAC {row.original.macLearning ? "✓" : "✗"}
</span>
),
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "status",
accessorKey: "status",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => (
<span
className={cn(
"text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap",
row.original.status === "up"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-red-500/10 text-red-500 border-red-500/20",
)}
>
{row.original.status === "up" ? "UP" : "DOWN"}
</span>
),
meta: {
headerTitle: "Статус",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const tunnel = row.original
return (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="outline"
size="icon"
className={cn(
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
"opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100",
"data-popup-open:opacity-100",
)}
aria-label={`Действия: ${tunnel.name}`}
>
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={() => onExport(tunnel)}>
<CodeXmlIcon className="size-4" />
Экспорт .rsc
</DropdownMenuItem>
<DropdownMenuItem>
<PencilIcon className="size-4" />
Редактировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<PowerIcon className="size-4" />
{tunnel.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" />
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
enableSorting: false,
size: 56,
meta: {
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[onExport, serverMap],
)
const table = useReactTable({
data: tunnels,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (tunnels.length === 0) {
return (
<EmptyState
icon={<NetworkIcon className="size-4" />}
title="Нет VXLAN-туннелей"
description="Добавьте туннель или измените поиск"
className="border-0 py-16"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={tunnels.length}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
}}
/>
)
}
export { VxlanDataGrid, type VxlanDataGridProps }
@@ -0,0 +1,276 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { WireGuardInterface } from "@/lib/data"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { WireGuardPeersDetail } from "@/components/data-grids/wireguard-peers-detail"
import { EmptyState } from "@/components/empty-state"
import {
ChevronDownIcon,
ChevronRightIcon,
CodeXmlIcon,
MoreHorizontalIcon,
PencilIcon,
PlusIcon,
PowerIcon,
ShieldCheckIcon,
Trash2Icon,
} from "lucide-react"
export interface WgIfaceWithServer extends WireGuardInterface {
serverId: string
serverName: string
serverCountry: string
}
interface WireguardDataGridProps {
interfaces: WgIfaceWithServer[]
onExport: (iface: WgIfaceWithServer) => void
}
function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
const columns = useMemo<ColumnDef<WgIfaceWithServer>[]>(
() => [
{
id: "name",
accessorKey: "name",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Интерфейс / Сервер" className="ml-1" />
),
cell: ({ row }) => {
const iface = row.original
const expanded = row.getIsExpanded()
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
return (
<div className="flex items-start gap-2 min-w-0">
{expanded ? (
<ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
)}
<div className="min-w-0">
<div className="flex items-center gap-2">
<span
className={cn(
"size-2 rounded-full shrink-0",
iface.status === "up" ? "bg-emerald-500 animate-pulse" : "bg-red-500",
)}
/>
<span className="font-mono font-semibold text-sm">{iface.name}</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
<Flag code={iface.serverCountry} size={12} />
{iface.serverName}
</div>
<p className="sr-only">
{onlinePeers}/{iface.peers.length} пиров
</p>
</div>
</div>
)
},
meta: {
headerTitle: "Интерфейс / Сервер",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (row: WgIfaceWithServer) => (
<WireGuardPeersDetail peers={row.peers} />
),
},
},
{
id: "listenPort",
accessorKey: "listenPort",
header: ({ column }) => <DataGridSortHeader column={column} title="Порт" />,
cell: ({ row }) => (
<span className="font-mono text-sm">{row.original.listenPort}</span>
),
meta: {
headerTitle: "Порт",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "mtu",
accessorKey: "mtu",
header: ({ column }) => <DataGridSortHeader column={column} title="MTU" />,
cell: ({ row }) => <span className="font-mono text-sm">{row.original.mtu}</span>,
meta: {
headerTitle: "MTU",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "peers",
header: () => (
<span className="text-xs font-medium text-muted-foreground">Пиры</span>
),
enableSorting: false,
cell: ({ row }) => {
const iface = row.original
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
return (
<span className="font-mono text-sm text-center block">
<span className="text-emerald-600 dark:text-emerald-400">{onlinePeers}</span>
<span className="text-muted-foreground">/{iface.peers.length}</span>
</span>
)
},
meta: {
headerTitle: "Пиры",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "status",
accessorKey: "status",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => (
<span
className={cn(
"text-[11px] font-mono px-2 py-0.5 rounded border",
row.original.status === "up"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-red-500/10 text-red-500 border-red-500/20",
)}
>
{row.original.status === "up" ? "UP" : "DOWN"}
</span>
),
meta: {
headerTitle: "Статус",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const iface = row.original
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="outline"
size="icon"
className={cn(
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
"opacity-0 transition-[opacity,background-color,color,border-color]",
"group-hover/row:opacity-100 focus-visible:opacity-100",
"data-popup-open:opacity-100 data-popup-open:bg-muted",
)}
onClick={(e) => e.stopPropagation()}
aria-label={`Действия: ${iface.name}`}
>
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={() => onExport(iface)}>
<CodeXmlIcon className="size-4" />
Экспорт .rsc
</DropdownMenuItem>
<DropdownMenuItem>
<PencilIcon className="size-4" />
Редактировать
</DropdownMenuItem>
<DropdownMenuItem>
<PlusIcon className="size-4" />
Добавить пира
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<PowerIcon className="size-4" />
{iface.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" />
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
enableSorting: false,
size: 56,
meta: {
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[onExport],
)
const table = useReactTable({
data: interfaces,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowId: (row) => row.id,
getRowCanExpand: () => true,
})
if (interfaces.length === 0) {
return (
<EmptyState
icon={<ShieldCheckIcon className="size-4" />}
title="Нет WireGuard интерфейсов"
description="Добавьте первый интерфейс или проверьте поиск"
className="border-0 py-16"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={interfaces.length}
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
}}
/>
)
}
export { WireguardDataGrid, type WireguardDataGridProps }
@@ -0,0 +1,81 @@
"use client"
import type { WireGuardPeer } from "@/lib/data"
import { cn } from "@/lib/utils"
import {
ArrowDownIcon,
ArrowUpIcon,
KeyRoundIcon,
} from "lucide-react"
function fmtBytes(n: number | undefined): string {
if (!n) return "—"
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)} ГБ`
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
if (n >= 1_000) return `${(n / 1_000).toFixed(0)} КБ`
return `${n} Б`
}
function truncKey(key: string): string {
if (key.length <= 20) return key
return `${key.slice(0, 8)}${key.slice(-8)}`
}
function WireGuardPeersDetail({ peers }: { peers: WireGuardPeer[] }) {
if (peers.length === 0) {
return (
<div className="px-5 py-4 text-xs text-muted-foreground text-center border-t border-border/50">
Нет пиров
</div>
)
}
return (
<div className="border-t border-border/50">
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-5 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<span>Public Key</span>
<span>Allowed IPs</span>
<span>Последнее рукопожатие</span>
<span>RX / TX</span>
<span>Endpoint</span>
</div>
{peers.map((peer) => (
<div
key={peer.publicKey}
className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-5 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20"
>
<div className="flex items-center gap-1.5 min-w-0">
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
{truncKey(peer.publicKey)}
</span>
</div>
<div className="font-mono text-muted-foreground truncate">
{peer.allowedIps.join(", ")}
</div>
<span
className={cn(
"font-mono text-[11px] whitespace-nowrap",
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
)}
>
{peer.latestHandshake ?? "нет рукопожатия"}
</span>
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
<span className="flex items-center gap-0.5">
<ArrowDownIcon className="size-3 text-emerald-500" />
{fmtBytes(peer.transferRx)}
</span>
<span className="flex items-center gap-0.5">
<ArrowUpIcon className="size-3 text-blue-400" />
{fmtBytes(peer.transferTx)}
</span>
</div>
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
</div>
))}
</div>
)
}
export { WireGuardPeersDetail, fmtBytes, truncKey }
+18
View File
@@ -0,0 +1,18 @@
import type { ReactNode } from "react"
import { Card } from "@/components/ui/card"
import { cn } from "@/lib/utils"
interface DataPageCardProps {
children: ReactNode
className?: string
}
function DataPageCard({ children, className }: DataPageCardProps) {
return (
<Card className={cn("overflow-hidden py-0 gap-0", className)}>
{children}
</Card>
)
}
export { DataPageCard, type DataPageCardProps }
+84
View File
@@ -0,0 +1,84 @@
"use client"
import { ReactNode } from "react"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import {
Filters,
type Filter,
type FilterFieldConfig,
} from "@/components/reui/filters"
import { SegmentedControl } from "@/components/form-kit"
interface DataPageToolbarProps<T extends string = string> {
search?: string
onSearchChange?: (value: string) => void
searchPlaceholder?: string
segmented?: {
value: T
onChange: (value: T) => void
options: { value: T; label: string; count?: number }[]
}
filters?: Filter[]
onFiltersChange?: (filters: Filter[]) => void
filterFields?: FilterFieldConfig[]
countLabel?: string
actions?: ReactNode
className?: string
}
function DataPageToolbar<T extends string = string>({
search,
onSearchChange,
searchPlaceholder = "Поиск…",
segmented,
filters,
onFiltersChange,
filterFields,
countLabel,
actions,
className,
}: DataPageToolbarProps<T>) {
return (
<div className={cn("flex items-center gap-3 px-5 py-3 border-b flex-wrap", className)}>
{segmented && (
<SegmentedControl
value={segmented.value}
onChange={segmented.onChange}
options={segmented.options}
/>
)}
{filterFields && filters && onFiltersChange && (
<Filters
filters={filters}
fields={filterFields}
onChange={onFiltersChange}
size="sm"
/>
)}
{onSearchChange != null && (
<InputGroup className="min-w-[220px] max-w-sm">
<InputGroupAddon>
<SearchIcon className="size-3.5" />
</InputGroupAddon>
<InputGroupInput
placeholder={searchPlaceholder}
value={search ?? ""}
onChange={(e) => onSearchChange(e.target.value)}
/>
</InputGroup>
)}
{countLabel && (
<span className="text-sm text-muted-foreground ml-auto">{countLabel}</span>
)}
{actions}
</div>
)
}
export { DataPageToolbar, type DataPageToolbarProps }
+94 -40
View File
@@ -1,13 +1,30 @@
"use client"
import { useMemo, useState } from "react"
import { Card } from "@/components/ui/card"
import { SearchIcon } from "lucide-react"
import {
type ColumnDef,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { EmptyState } from "@/components/empty-state"
import { InboxIcon } from "lucide-react"
export interface Column<T> {
key: string
label: string
render: (row: T) => React.ReactNode
enableSorting?: boolean
}
interface DataTableProps<T extends { id: string }> {
@@ -15,6 +32,11 @@ interface DataTableProps<T extends { id: string }> {
columns: Column<T>[]
searchPlaceholder?: string
searchKeys?: (keyof T)[]
isLoading?: boolean
emptyTitle?: string
emptyDescription?: string
pagination?: boolean
countLabel?: string
}
export function DataTable<T extends { id: string }>({
@@ -22,10 +44,15 @@ export function DataTable<T extends { id: string }>({
columns,
searchPlaceholder = "Поиск…",
searchKeys = [],
isLoading = false,
emptyTitle = "Нет записей",
emptyDescription,
pagination = false,
countLabel,
}: DataTableProps<T>) {
const [search, setSearch] = useState("")
const filtered = useMemo(() => {
const filteredData = useMemo(() => {
if (!search || searchKeys.length === 0) return data
const s = search.toLowerCase()
return data.filter((row) =>
@@ -33,44 +60,71 @@ export function DataTable<T extends { id: string }>({
)
}, [data, search, searchKeys])
const columnDefs = useMemo<ColumnDef<T>[]>(
() => {
const defs: ColumnDef<T>[] = columns.map((col, index) => ({
id: col.key,
accessorKey: col.key,
header: ({ column }) => (
<DataGridSortHeader column={column} title={col.label} />
),
cell: ({ row }) => col.render(row.original),
enableSorting: col.enableSorting !== false,
meta: {
headerTitle: col.label,
headerClassName:
index === 0
? DATA_GRID_CELL_PAD_FIRST
: index === columns.length - 1
? DATA_GRID_CELL_PAD_LAST
: DATA_GRID_CELL_PAD,
cellClassName:
index === 0
? DATA_GRID_CELL_PAD_FIRST
: index === columns.length - 1
? DATA_GRID_CELL_PAD_LAST
: DATA_GRID_CELL_PAD,
},
}))
return defs
},
[columns],
)
const table = useReactTable({
data: filteredData,
columns: columnDefs,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
...(pagination ? { getPaginationRowModel: getPaginationRowModel() } : {}),
getRowId: (row) => row.id,
})
const displayCount = searchKeys.length > 0 ? filteredData.length : data.length
return (
<Card>
<div className="flex items-center gap-2 px-5 py-3 border-b">
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder={searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={searchPlaceholder}
countLabel={countLabel ?? `${displayCount} записей`}
/>
<DataGridShell
table={table}
recordCount={filteredData.length}
isLoading={isLoading}
loadingMode="skeleton"
pagination={pagination}
emptyMessage={
<EmptyState
icon={<InboxIcon className="size-4" />}
title={emptyTitle}
description={emptyDescription}
className="border-0 py-12"
/>
</div>
<span className="text-sm text-muted-foreground ml-1">{filtered.length} записей</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
{columns.map((col) => (
<th key={col.key} className="text-left font-medium px-5 py-3">
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((row) => (
<tr key={row.id} className="hover:bg-muted/40 transition-colors">
{columns.map((col) => (
<td key={col.key} className="px-5 py-3">
{col.render(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
}
/>
</DataPageCard>
)
}
+32
View File
@@ -0,0 +1,32 @@
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import { cn } from "@/lib/utils"
interface EmptyStateProps {
icon?: React.ReactNode
title: string
description?: string
action?: React.ReactNode
className?: string
}
function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
return (
<Empty className={cn("border-0 py-16", className)}>
<EmptyHeader>
{icon && <EmptyMedia variant="icon">{icon}</EmptyMedia>}
<EmptyTitle>{title}</EmptyTitle>
{description && <EmptyDescription>{description}</EmptyDescription>}
</EmptyHeader>
{action && <EmptyContent>{action}</EmptyContent>}
</Empty>
)
}
export { EmptyState, type EmptyStateProps }
+120
View File
@@ -0,0 +1,120 @@
"use client"
import { useCallback, useState } from "react"
import { UploadIcon } from "lucide-react"
import { toast } from "sonner"
import { useFileUpload } from "@/hooks/use-file-upload"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
interface FileImportDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
accept?: string
multiple?: boolean
onImport: (files: File[]) => void | Promise<void>
}
function FileImportDialog({
open,
onOpenChange,
title,
description,
accept = "*",
multiple = false,
onImport,
}: FileImportDialogProps) {
const [importing, setImporting] = useState(false)
const [{ files, isDragging, errors }, actions] = useFileUpload({
accept,
multiple,
maxFiles: multiple ? 10 : 1,
onError: (errs) => {
errs.forEach((e) => toast.error(e))
},
})
const handleImport = useCallback(async () => {
const rawFiles = files
.map((f) => (f.file instanceof File ? f.file : null))
.filter((f): f is File => f != null)
if (rawFiles.length === 0) {
toast.error("Выберите файл для импорта")
return
}
setImporting(true)
try {
await onImport(rawFiles)
actions.clearFiles()
onOpenChange(false)
toast.success("Импорт выполнен")
} catch (err) {
toast.error(err instanceof Error ? err.message : "Ошибка импорта")
} finally {
setImporting(false)
}
}, [actions, files, onImport, onOpenChange])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<div
className={cn(
"flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center transition-colors",
isDragging ? "border-primary bg-primary/5" : "border-border",
)}
onDragEnter={actions.handleDragEnter}
onDragLeave={actions.handleDragLeave}
onDragOver={actions.handleDragOver}
onDrop={actions.handleDrop}
>
<UploadIcon className="size-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">
Перетащите файл сюда или выберите на диске
</p>
<Button type="button" variant="outline" size="sm" onClick={actions.openFileDialog}>
Выбрать файл
</Button>
<input {...actions.getInputProps()} className="sr-only" />
{files.length > 0 && (
<ul className="w-full text-left text-sm">
{files.map((f) => (
<li key={f.id} className="truncate font-mono text-xs">
{f.file instanceof File ? f.file.name : f.file.name}
</li>
))}
</ul>
)}
{errors.map((e) => (
<p key={e} className="text-xs text-destructive">{e}</p>
))}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button type="button" onClick={handleImport} disabled={importing || files.length === 0}>
{importing ? "Импорт…" : "Импортировать"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export { FileImportDialog, type FileImportDialogProps }
+31
View File
@@ -0,0 +1,31 @@
"use client"
import {
Field,
FieldDescription,
FieldLabel,
} from "@/components/ui/field"
interface FormFieldProps {
label: string
hint?: string
error?: string
required?: boolean
children: React.ReactNode
}
function FormField({ label, hint, error, required, children }: FormFieldProps) {
return (
<Field data-invalid={!!error}>
<FieldLabel>
{label}
{required && <span className="text-destructive ml-0.5">*</span>}
</FieldLabel>
{children}
{hint && !error && <FieldDescription>{hint}</FieldDescription>}
{error && <FieldDescription className="text-destructive">{error}</FieldDescription>}
</Field>
)
}
export { FormField, type FormFieldProps }
+24
View File
@@ -0,0 +1,24 @@
"use client"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
interface FormToggleProps {
checked: boolean
onChange: (value: boolean) => void
disabled?: boolean
className?: string
}
function FormToggle({ checked, onChange, disabled, className }: FormToggleProps) {
return (
<Switch
checked={checked}
onCheckedChange={onChange}
disabled={disabled}
className={cn(className)}
/>
)
}
export { FormToggle, type FormToggleProps }
+4
View File
@@ -0,0 +1,4 @@
export { FormField, type FormFieldProps } from "./form-field"
export { FormToggle, type FormToggleProps } from "./form-toggle"
export { SectionTitle, type SectionTitleProps } from "./section-title"
export { SegmentedControl, type SegmentedControlProps } from "./segmented-control"
+21
View File
@@ -0,0 +1,21 @@
import { cn } from "@/lib/utils"
interface SectionTitleProps {
children: React.ReactNode
icon?: React.ReactNode
className?: string
}
function SectionTitle({ children, icon, className }: SectionTitleProps) {
return (
<div className={cn("flex items-center gap-2 py-0.5", className)}>
{icon}
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{children}
</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
export { SectionTitle, type SectionTitleProps }
+47
View File
@@ -0,0 +1,47 @@
"use client"
import { cn } from "@/lib/utils"
interface SegmentedControlProps<T extends string> {
value: T
onChange: (value: T) => void
options: { value: T; label: string; count?: number }[]
className?: string
}
function SegmentedControl<T extends string>({
value,
onChange,
options,
className,
}: SegmentedControlProps<T>) {
return (
<div
className={cn(
"flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit",
className,
)}
>
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
"flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors",
value === option.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
{option.count != null && (
<span className="text-xs tabular-nums opacity-60">{option.count}</span>
)}
</button>
))}
</div>
)
}
export { SegmentedControl, type SegmentedControlProps }
+92
View File
@@ -0,0 +1,92 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
[
"relative w-full text-sm border has-[>svg]:grid-cols-[calc(var(--spacing)*3)_1fr] grid-cols-[0_1fr] grid gap-y-0.5 items-center [&>svg:not([class*=size-])]:size-4",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_[data-slot=alert-action]]:sm:row-end-3",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:items-start",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_svg]:translate-y-0.5",
"rounded-lg",
"px-3",
"py-2.5",
"has-[>svg]:gap-x-2.5",
],
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"border-destructive/30 bg-destructive/4 [&>svg]:text-destructive",
info: "border-info/30 bg-info/4 [&>svg]:text-info",
success: "border-success/30 bg-success/4 [&>svg]:text-success",
warning: "border-warning/30 bg-warning/4 [&>svg]:text-warning",
invert:
"border-invert bg-invert text-invert-foreground [&_[data-slot=alert-description]]:text-invert-foreground/70",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn(
"flex gap-1.5 max-sm:col-start-2 max-sm:mt-2 max-sm:justify-start sm:col-start-3 sm:row-start-1 sm:justify-end sm:self-center",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
+343
View File
@@ -0,0 +1,343 @@
"use client"
import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { ScrollArea } from "@/components/ui/scroll-area"
import { XIcon, ChevronsUpDownIcon } from "lucide-react"
const inputVariants = cva(
"outline-none flex w-full text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [[readonly]]:bg-muted/80 [[readonly]]:cursor-not-allowed border border-input focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg bg-transparent dark:bg-input/30 text-sm transition-colors focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-3",
{
variants: {
size: {
sm: "h-7 px-2 [&~[data-slot=autocomplete-clear]]:end-1.5 [&~[data-slot=autocomplete-trigger]]:end-1.5",
default:
"h-8 px-2.5 [&~[data-slot=autocomplete-clear]]:end-1.75 [&~[data-slot=autocomplete-trigger]]:end-1.75",
lg: "h-9 px-2.5 [&~[data-slot=autocomplete-clear]]:end-2 [&~[data-slot=autocomplete-trigger]]:end-2",
},
},
defaultVariants: {
size: "default",
},
}
)
const Autocomplete = AutocompletePrimitive.Root
function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
return (
<AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />
)
}
function AutocompleteInput({
className,
size = "default",
showClear = false,
showTrigger = false,
...props
}: Omit<AutocompletePrimitive.Input.Props, "size"> &
VariantProps<typeof inputVariants> & {
showClear?: boolean
showTrigger?: boolean
}) {
return (
<div className="relative w-full">
<AutocompletePrimitive.Input
data-slot="autocomplete-input"
data-size={size}
className={cn(inputVariants({ size }), className)}
{...props}
/>
{showTrigger && <AutocompleteTrigger />}
{showClear && <AutocompleteClear />}
</div>
)
}
function AutocompleteStatus({
className,
...props
}: AutocompletePrimitive.Status.Props) {
return (
<AutocompletePrimitive.Status
data-slot="autocomplete-status"
className={cn(
"text-muted-foreground px-2 py-1.5 text-sm empty:m-0 empty:p-0",
className
)}
{...props}
/>
)
}
function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) {
return (
<AutocompletePrimitive.Portal data-slot="autocomplete-portal" {...props} />
)
}
function AutocompleteBackdrop({
...props
}: AutocompletePrimitive.Backdrop.Props) {
return (
<AutocompletePrimitive.Backdrop
data-slot="autocomplete-backdrop"
{...props}
/>
)
}
function AutocompletePositioner({
className,
...props
}: AutocompletePrimitive.Positioner.Props) {
return (
<AutocompletePrimitive.Positioner
data-slot="autocomplete-positioner"
className={cn("z-50 outline-none", className)}
{...props}
/>
)
}
function AutocompleteList({
className,
scrollAreaClassName,
...props
}: AutocompletePrimitive.List.Props & {
scrollAreaClassName?: string
scrollFade?: boolean
scrollbarGutter?: boolean
}) {
return (
<ScrollArea
className={cn(
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain",
scrollAreaClassName
)}
>
<AutocompletePrimitive.List
data-slot="autocomplete-list"
className={cn(
"not-empty:px-1 not-empty:py-1 not-empty:scroll-py-1 in-data-has-overflow-y:me-3",
className
)}
{...props}
/>
</ScrollArea>
)
}
function AutocompleteCollection({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Collection>) {
return (
<AutocompletePrimitive.Collection
data-slot="autocomplete-collection"
{...props}
/>
)
}
function AutocompleteRow({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Row>) {
return (
<AutocompletePrimitive.Row
data-slot="autocomplete-row"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function AutocompleteItem({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Item>) {
return (
<AutocompletePrimitive.Item
data-slot="autocomplete-item"
className={cn(
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5 rounded-md px-1.5 py-1 text-sm data-highlighted:before:rounded-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
className
)}
{...props}
/>
)
}
export interface AutocompleteContentProps extends React.ComponentProps<
typeof AutocompletePrimitive.Popup
> {
align?: AutocompletePrimitive.Positioner.Props["align"]
sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"]
alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"]
side?: AutocompletePrimitive.Positioner.Props["side"]
anchor?: AutocompletePrimitive.Positioner.Props["anchor"]
showBackdrop?: boolean
}
function AutocompleteContent({
className,
children,
showBackdrop = false,
align = "start",
sideOffset = 4,
alignOffset = 0,
side = "bottom",
anchor,
...props
}: AutocompleteContentProps) {
return (
<AutocompletePortal>
{showBackdrop && <AutocompleteBackdrop />}
<AutocompletePositioner
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
side={side}
anchor={anchor}
>
<div className="relative flex max-h-full">
<AutocompletePrimitive.Popup
data-slot="autocomplete-popup"
className={cn(
"bg-popover text-popover-foreground rounded-lg shadow-md ring-foreground/10 flex max-h-[min(var(--available-height),24rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) scroll-pt-2 scroll-pb-2 flex-col overscroll-contain py-0.5 ring-1 transition-[scale,opacity] has-data-starting-style:scale-98 has-data-starting-style:opacity-0 has-data-[side=none]:scale-100 has-data-[side=none]:transition-none",
className
)}
{...props}
>
{children}
</AutocompletePrimitive.Popup>
</div>
</AutocompletePositioner>
</AutocompletePortal>
)
}
function AutocompleteGroup({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Group>) {
return (
<AutocompletePrimitive.Group data-slot="autocomplete-group" {...props} />
)
}
function AutocompleteGroupLabel({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.GroupLabel>) {
return (
<AutocompletePrimitive.GroupLabel
data-slot="autocomplete-group-label"
className={cn(
"text-muted-foreground px-1.5 py-1 text-xs font-medium",
className
)}
{...props}
/>
)
}
function AutocompleteEmpty({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Empty>) {
return (
<AutocompletePrimitive.Empty
data-slot="autocomplete-empty"
className={cn(
"text-muted-foreground px-2 py-1.5 text-sm text-center empty:m-0 empty:p-0",
className
)}
{...props}
/>
)
}
function AutocompleteClear({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Clear>) {
return (
<AutocompletePrimitive.Clear
data-slot="autocomplete-clear"
className={cn(
"ring-offset-background focus:ring-ring absolute top-1/2 -translate-y-1/2 cursor-pointer opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-disabled:pointer-events-none",
className
)}
{...props}
>
<XIcon className="size-4" />
</AutocompletePrimitive.Clear>
)
}
function AutocompleteTrigger({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Trigger>) {
return (
<AutocompletePrimitive.Trigger
data-slot="autocomplete-trigger"
className={cn(
"focus:ring-ring ring-offset-background absolute top-1/2 -translate-y-1/2 cursor-pointer focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none has-[+[data-slot=autocomplete-clear]]:hidden data-disabled:pointer-events-none",
className
)}
{...props}
>
<ChevronsUpDownIcon className="size-4 opacity-70" />
</AutocompletePrimitive.Trigger>
)
}
function AutocompleteArrow({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Arrow>) {
return (
<AutocompletePrimitive.Arrow data-slot="autocomplete-arrow" {...props} />
)
}
function AutocompleteSeparator({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Separator>) {
return (
<AutocompletePrimitive.Separator
data-slot="autocomplete-separator"
className={cn(
"bg-border my-1.5 h-px",
className
)}
{...props}
/>
)
}
export {
Autocomplete,
AutocompleteValue,
AutocompleteTrigger,
AutocompleteInput,
AutocompleteStatus,
AutocompletePortal,
AutocompleteBackdrop,
AutocompletePositioner,
AutocompleteContent,
AutocompleteList,
AutocompleteCollection,
AutocompleteRow,
AutocompleteItem,
AutocompleteGroup,
AutocompleteGroupLabel,
AutocompleteEmpty,
AutocompleteClear,
AutocompleteArrow,
AutocompleteSeparator,
}
+98
View File
@@ -0,0 +1,98 @@
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"
const badgeVariants = cva(
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground",
outline: "border-border bg-transparent dark:bg-input/32",
secondary: "bg-secondary text-secondary-foreground",
info: "bg-info text-white",
success: "bg-success text-white",
warning: "bg-warning text-white",
destructive: "bg-destructive text-white",
focus: "bg-focus text-focus-foreground",
invert: "bg-invert text-invert-foreground",
"primary-light":
"bg-primary/10 border-none text-primary dark:bg-primary/20",
"warning-light":
"bg-warning/10 border-none text-warning-foreground dark:bg-warning/20",
"success-light":
"bg-success/10 border-none text-success-foreground dark:bg-success/20",
"info-light":
"bg-info/10 border-none text-info-foreground dark:bg-info/20",
"destructive-light":
"bg-destructive/10 border-none text-destructive-foreground dark:bg-destructive/20",
"invert-light":
"bg-invert/10 border-none text-foreground dark:bg-invert/20",
"focus-light":
"bg-focus/10 border-none text-focus-foreground dark:bg-focus/20",
"primary-outline":
"bg-background border-border text-primary dark:bg-input/30",
"warning-outline":
"bg-background border-border text-warning-foreground dark:bg-input/30",
"success-outline":
"bg-background border-border text-success-foreground dark:bg-input/30",
"info-outline":
"bg-background border-border text-info-foreground dark:bg-input/30",
"destructive-outline":
"bg-background border-border text-destructive-foreground dark:bg-input/30",
"invert-outline":
"bg-background border-border text-invert-foreground dark:bg-input/30",
"focus-outline":
"bg-background border-border text-focus-foreground dark:bg-input/30",
},
size: {
xs: "px-1 py-0.25 text-[0.6rem] leading-none h-4 min-w-4 gap-1",
sm: "px-1 py-0.25 text-[0.625rem] leading-none h-4.5 min-w-4.5 gap-1",
default: "px-1.25 py-0.5 text-xs h-5 min-w-5 gap-1",
lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1",
xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5",
},
/** `default`: per-theme radius. `full`: max radius per theme (Lyra stays `rounded-none`). */
radius: {
default:
"rounded-sm",
full: "rounded-full",
},
},
defaultVariants: {
variant: "default",
size: "default",
radius: "default",
},
}
)
interface BadgeProps extends useRender.ComponentProps<"span"> {
variant?: VariantProps<typeof badgeVariants>["variant"]
size?: VariantProps<typeof badgeVariants>["size"]
radius?: VariantProps<typeof badgeVariants>["radius"]
}
function Badge({
className,
variant,
size,
radius,
render,
...props
}: BadgeProps) {
const defaultProps = {
"data-slot": "badge",
className: cn(badgeVariants({ variant, size, radius, className })),
}
return useRender({
defaultTagName: "span",
render,
props: mergeProps<"span">(defaultProps, props),
})
}
export { Badge, badgeVariants, type BadgeProps }
@@ -0,0 +1,167 @@
"use client"
import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { Column } from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Separator } from "@/components/ui/separator"
import { CirclePlusIcon, CheckIcon } from "lucide-react"
interface DataGridColumnFilterProps<TData, TValue> {
column?: Column<TData, TValue>
title?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
}
function DataGridColumnFilter<TData, TValue>({
column,
title,
options,
}: DataGridColumnFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues()
const selectedValues = new Set(column?.getFilterValue() as string[])
const [searchQuery, setSearchQuery] = useState("")
const filteredOptions = useMemo(() => {
if (!searchQuery) return options
return options.filter((option) =>
option.label.toLowerCase().includes(searchQuery.toLowerCase())
)
}, [options, searchQuery])
return (
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm">
<CirclePlusIcon className="size-4" />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal"
>
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="secondary"
key={option.value}
className="rounded-sm px-1 font-normal"
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
}
/>
<PopoverContent className="w-[200px] p-0" align="start">
<div className="p-2">
<Input
placeholder={title}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8"
/>
</div>
<div className="max-h-[300px] overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground py-6 text-center text-sm">
No results found.
</div>
) : (
<div className="p-1">
{filteredOptions.map((option) => {
const isSelected = selectedValues.has(option.value)
return (
<div
key={option.value}
onClick={() => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
)}
>
<div
className={cn(
"border-primary me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
{option.icon && (
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
)}
<span>{option.label}</span>
{facets?.get(option.value) && (
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
</span>
)}
</div>
)
})}
</div>
)}
{selectedValues.size > 0 && (
<>
<div className="bg-border -mx-1 my-1 h-px" />
<div className="p-1">
<div
onClick={() => column?.setFilterValue(undefined)}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
>
Clear filters
</div>
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
)
}
export { DataGridColumnFilter, type DataGridColumnFilterProps }
@@ -0,0 +1,345 @@
"use client"
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
import {
getColumnHeaderLabel,
useDataGrid,
} from "@/components/reui/data-grid/data-grid"
import { Column } from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
interface DataGridColumnHeaderProps<
TData,
TValue,
> extends HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
title?: string
icon?: ReactNode
pinnable?: boolean
filter?: ReactNode
visibility?: boolean
}
function DataGridColumnHeaderInner<TData, TValue>({
column,
title,
icon,
className,
filter,
visibility = false,
}: DataGridColumnHeaderProps<TData, TValue>) {
const { isLoading, table, props, recordCount } = useDataGrid()
const resolvedTitle = title ?? getColumnHeaderLabel(column)
const columnOrder = table.getState().columnOrder
const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility)
const isSorted = column.getIsSorted()
const isPinned = column.getIsPinned()
const canSort = column.getCanSort()
const canPin = column.getCanPin()
const canResize = column.getCanResize()
const columnIndex = columnOrder.indexOf(column.id)
const canMoveLeft = columnIndex > 0
const canMoveRight = columnIndex < columnOrder.length - 1
const handleSort = () => {
if (isSorted === "asc") {
column.toggleSorting(true)
} else if (isSorted === "desc") {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}
const headerLabelClassName = cn(
"text-secondary-foreground/80 inline-flex h-full items-center gap-1.5 font-normal [&_svg]:opacity-60 text-[0.8125rem] leading-[calc(1.125/0.8125)] [&_svg]:size-3.5",
className
)
const headerButtonClassName = cn(
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg",
className
)
const sortIcon =
canSort &&
(isSorted === "desc" ? (
<ArrowDownIcon className="size-3.25" />
) : isSorted === "asc" ? (
<ArrowUpIcon className="size-3.25" />
) : (
<ChevronsUpDownIcon className="mt-px size-3.25" />
))
const hasControls =
props.tableLayout?.columnsMovable ||
(props.tableLayout?.columnsVisibility && visibility) ||
(props.tableLayout?.columnsPinnable && canPin) ||
filter
const menuItems = useMemo(() => {
const items: ReactNode[] = []
let hasPreviousSection = false
// Filter section
if (filter) {
items.push(
<DropdownMenuGroup key="group-filter">
<DropdownMenuLabel key="filter">{filter}</DropdownMenuLabel>
</DropdownMenuGroup>
)
hasPreviousSection = true
}
// Sort section
if (canSort) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-sort" />)
}
items.push(
<DropdownMenuItem
key="sort-asc"
onClick={() => {
if (isSorted === "asc") {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}}
disabled={!canSort}
>
<ArrowUpIcon className="size-3.5!" />
<span className="grow">Asc</span>
{isSorted === "asc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>,
<DropdownMenuItem
key="sort-desc"
onClick={() => {
if (isSorted === "desc") {
column.clearSorting()
} else {
column.toggleSorting(true)
}
}}
disabled={!canSort}
>
<ArrowDownIcon className="size-3.5!" />
<span className="grow">Desc</span>
{isSorted === "desc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Pin section
if (props.tableLayout?.columnsPinnable && canPin) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-pin" />)
}
items.push(
<DropdownMenuItem
key="pin-left"
onClick={() => column.pin(isPinned === "left" ? false : "left")}
>
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to left</span>
{isPinned === "left" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>,
<DropdownMenuItem
key="pin-right"
onClick={() => column.pin(isPinned === "right" ? false : "right")}
>
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to right</span>
{isPinned === "right" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Move section
if (props.tableLayout?.columnsMovable) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-move" />)
}
items.push(
<DropdownMenuItem
key="move-left"
onClick={() => {
if (columnIndex > 0) {
const newOrder = [...columnOrder]
const [movedColumn] = newOrder.splice(columnIndex, 1)
newOrder.splice(columnIndex - 1, 0, movedColumn)
table.setColumnOrder(newOrder)
}
}}
disabled={!canMoveLeft || isPinned !== false}
>
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Left</span>
</DropdownMenuItem>,
<DropdownMenuItem
key="move-right"
onClick={() => {
if (columnIndex < columnOrder.length - 1) {
const newOrder = [...columnOrder]
const [movedColumn] = newOrder.splice(columnIndex, 1)
newOrder.splice(columnIndex + 1, 0, movedColumn)
table.setColumnOrder(newOrder)
}
}}
disabled={!canMoveRight || isPinned !== false}
>
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Right</span>
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Visibility section
if (props.tableLayout?.columnsVisibility && visibility) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-visibility" />)
}
items.push(
<DropdownMenuSub key="visibility">
<DropdownMenuSubTrigger>
<Settings2Icon className="size-3.5!" />
<span>Columns</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent side="right">
{table
.getAllColumns()
.filter((col) => col.getCanHide())
.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={col.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => col.toggleVisibility(!!value)}
className="capitalize"
>
{getColumnHeaderLabel(col)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
return items
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
filter,
canSort,
isSorted,
column,
props.tableLayout?.columnsPinnable,
props.tableLayout?.columnsMovable,
props.tableLayout?.columnsVisibility,
canPin,
isPinned,
canMoveLeft,
canMoveRight,
visibility,
table,
columnIndex,
columnOrder,
columnVisibilityKey, // Needed to update checkbox states when visibility changes
])
if (hasControls) {
return (
<div className="flex h-full items-center justify-between gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
>
{icon && icon}
{resolvedTitle}
{sortIcon}
</Button>
}
/>
<DropdownMenuContent className="w-40" align="start">
{menuItems}
</DropdownMenuContent>
</DropdownMenu>
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
<Button
size="icon-sm"
variant="ghost"
className="-me-1 size-7 rounded-md"
onClick={() => column.pin(false)}
aria-label={`Unpin ${resolvedTitle} column`}
title={`Unpin ${resolvedTitle} column`}
>
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
</Button>
)}
</div>
)
}
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
return (
<div className="flex h-full items-center">
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
onClick={handleSort}
>
{icon && icon}
{resolvedTitle}
{sortIcon}
</Button>
</div>
)
}
return (
<div className={headerLabelClassName}>
{icon && icon}
{resolvedTitle}
</div>
)
}
const DataGridColumnHeader = memo(
DataGridColumnHeaderInner
) as typeof DataGridColumnHeaderInner
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
@@ -0,0 +1,53 @@
"use client"
import { ReactElement } from "react"
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
import { Table } from "@tanstack/react-table"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
function DataGridColumnVisibility<TData>({
table,
trigger,
}: {
table: Table<TData>
trigger: ReactElement<Record<string, unknown>>
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
<DropdownMenuContent align="end" className="min-w-[150px]">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-medium">
Toggle Columns
</DropdownMenuLabel>
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{getColumnHeaderLabel(column)}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export { DataGridColumnVisibility }
@@ -0,0 +1,226 @@
"use client"
import React, { ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
interface DataGridPaginationProps {
sizes?: number[]
sizesInfo?: string
sizesLabel?: string
sizesDescription?: string
sizesSkeleton?: ReactNode
more?: boolean
moreLimit?: number
info?: string
infoSkeleton?: ReactNode
className?: string
rowsPerPageLabel?: string
previousPageLabel?: string
nextPageLabel?: string
ellipsisText?: string
}
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
const { table, recordCount, isLoading } = useDataGrid()
const defaultProps: Partial<DataGridPaginationProps> = {
sizes: [5, 10, 25, 50, 100],
sizesLabel: "Show",
sizesDescription: "per page",
sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5,
more: false,
info: "{from} - {to} of {count}",
infoSkeleton: <Skeleton className="h-8 w-60" />,
rowsPerPageLabel: "Rows per page",
previousPageLabel: "Go to previous page",
nextPageLabel: "Go to next page",
ellipsisText: "...",
}
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
const btnBaseClasses = "size-7 p-0 text-sm"
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
const pageIndex = table.getState().pagination.pageIndex
const pageSize = table.getState().pagination.pageSize
const from = pageIndex * pageSize + 1
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
const pageCount = table.getPageCount()
// Replace placeholders in paginationInfo
const paginationInfo = mergedProps?.info
? mergedProps.info
.replace("{from}", from.toString())
.replace("{to}", to.toString())
.replace("{count}", recordCount.toString())
: `${from} - ${to} of ${recordCount}`
// Pagination limit logic
const paginationMoreLimit = mergedProps?.moreLimit || 5
// Determine the start and end of the pagination group
const currentGroupStart =
Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit
const currentGroupEnd = Math.min(
currentGroupStart + paginationMoreLimit,
pageCount
)
// Render page buttons based on the current group
const renderPageButtons = () => {
const buttons = []
for (let i = currentGroupStart; i < currentGroupEnd; i++) {
buttons.push(
<Button
key={i}
size="icon-sm"
variant="ghost"
className={cn(btnBaseClasses, "text-muted-foreground", {
"bg-accent text-accent-foreground": pageIndex === i,
})}
onClick={() => {
if (pageIndex !== i) {
table.setPageIndex(i)
}
}}
>
{i + 1}
</Button>
)
}
return buttons
}
// Render a "previous" ellipsis button if there are previous pages to show
const renderEllipsisPrevButton = () => {
if (currentGroupStart > 0) {
return (
<Button
size="icon-sm"
className={btnBaseClasses}
variant="ghost"
onClick={() => table.setPageIndex(currentGroupStart - 1)}
>
{mergedProps.ellipsisText}
</Button>
)
}
return null
}
// Render a "next" ellipsis button if there are more pages to show after the current group
const renderEllipsisNextButton = () => {
if (currentGroupEnd < pageCount) {
return (
<Button
className={btnBaseClasses}
variant="ghost"
size="icon-sm"
onClick={() => table.setPageIndex(currentGroupEnd)}
>
{mergedProps.ellipsisText}
</Button>
)
}
return null
}
return (
<div
data-slot="data-grid-pagination"
className={cn(
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
mergedProps?.className
)}
>
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
{isLoading ? (
mergedProps?.sizesSkeleton
) : (
<>
<div className="text-muted-foreground text-sm">
{mergedProps.rowsPerPageLabel}
</div>
<Select
value={`${pageSize}`}
onValueChange={(value) => {
const newPageSize = Number(value)
table.setPageSize(newPageSize)
}}
>
<SelectTrigger className="w-14" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent side="top" className="min-w-18">
{mergedProps?.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
{isLoading ? (
mergedProps?.infoSkeleton
) : (
<>
<div className="text-muted-foreground text-sm order-2 text-nowrap sm:order-1">
{paginationInfo}
</div>
{pageCount > 1 && (
<div className="order-1 flex items-center space-x-1 sm:order-2">
<Button
size="icon-sm"
variant="ghost"
className={btnArrowClasses}
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">
{mergedProps.previousPageLabel}
</span>
<ChevronLeftIcon className="size-4" />
</Button>
{renderEllipsisPrevButton()}
{renderPageButtons()}
{renderEllipsisNextButton()}
<Button
size="icon-sm"
variant="ghost"
className={btnArrowClasses}
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">{mergedProps.nextPageLabel}</span>
<ChevronRightIcon className="size-4" />
</Button>
</div>
)}
</>
)}
</div>
</div>
)
}
export { DataGridPagination, type DataGridPaginationProps }
@@ -0,0 +1,421 @@
"use client"
import {
PointerEvent,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
const MIN_THUMB_SIZE = 24
const FALLBACK_SCROLLBAR_SIZE = 12
const INITIAL_METRICS = {
hasVerticalOverflow: false,
headerHeight: 0,
horizontalScrollbarSize: 0,
thumbHeight: 0,
thumbTop: 0,
trackHeight: 0,
} as const
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
type ScrollbarMetrics = {
hasVerticalOverflow: boolean
headerHeight: number
horizontalScrollbarSize: number
thumbHeight: number
thumbTop: number
trackHeight: number
}
type ObservedElements = {
header: HTMLElement | null
horizontalScrollbar: HTMLElement | null
table: HTMLElement | null
tableViewport: HTMLElement | null
}
type DataGridScrollAreaProps = Omit<
ScrollAreaPrimitive.Root.Props,
"children"
> & {
children: ReactNode
orientation?: DataGridScrollAreaOrientation
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) {
return (
next.hasVerticalOverflow === prev.hasVerticalOverflow &&
next.headerHeight === prev.headerHeight &&
next.horizontalScrollbarSize === prev.horizontalScrollbarSize &&
next.thumbHeight === prev.thumbHeight &&
next.thumbTop === prev.thumbTop &&
next.trackHeight === prev.trackHeight
)
}
function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) {
element.style.setProperty(
"--data-grid-scrollbar-header-height",
`${metrics.headerHeight}px`
)
element.style.setProperty(
"--data-grid-scrollbar-thumb-height",
`${metrics.thumbHeight}px`
)
element.style.setProperty(
"--data-grid-scrollbar-thumb-top",
`${metrics.thumbTop}px`
)
element.style.setProperty(
"--data-grid-scrollbar-track-height",
`${metrics.trackHeight}px`
)
}
function DataGridScrollArea({
children,
className,
orientation = "both",
...props
}: DataGridScrollAreaProps) {
const { props: dataGridProps } = useDataGrid()
const containerRef = useRef<HTMLDivElement>(null)
const viewportRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<{
pointerId: number
startScrollTop: number
startY: number
} | null>(null)
const metricsRef = useRef<ScrollbarMetrics>(INITIAL_METRICS)
const observedElementsRef = useRef<ObservedElements>({
header: null,
horizontalScrollbar: null,
table: null,
tableViewport: null,
})
const showHorizontal = orientation !== "vertical"
const showVertical = orientation !== "horizontal"
const usesCustomVerticalScrollbar =
showVertical && !!dataGridProps.tableLayout?.headerSticky
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
useState(false)
const clearDragState = useCallback(() => {
dragRef.current = null
document.body.style.userSelect = ""
document.body.style.webkitUserSelect = ""
}, [])
const resetMetrics = useCallback(() => {
const container = containerRef.current
if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
applyMetrics(container, INITIAL_METRICS)
metricsRef.current = INITIAL_METRICS
}
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
}, [])
const syncCustomVerticalScrollbar = useCallback(() => {
const container = containerRef.current
const viewport = viewportRef.current
if (!container || !viewport || !usesCustomVerticalScrollbar) {
resetMetrics()
return
}
const { header, horizontalScrollbar } = observedElementsRef.current
const headerHeight = header?.getBoundingClientRect().height ?? 0
const viewportHeight = viewport.clientHeight
const viewportWidth = viewport.clientWidth
const scrollHeight = viewport.scrollHeight
const scrollWidth = viewport.scrollWidth
const hasHorizontalOverflow =
showHorizontal && scrollWidth > viewportWidth + 0.5
const horizontalScrollbarSize = hasHorizontalOverflow
? horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE
: 0
const trackHeight = Math.max(
0,
viewportHeight - headerHeight - horizontalScrollbarSize
)
const maxScroll = Math.max(0, scrollHeight - viewportHeight)
let nextMetrics: ScrollbarMetrics
if (trackHeight === 0 || maxScroll === 0) {
nextMetrics = {
hasVerticalOverflow: false,
headerHeight,
horizontalScrollbarSize,
thumbHeight: trackHeight,
thumbTop: 0,
trackHeight,
}
} else {
const bodyContentHeight = Math.max(
trackHeight,
scrollHeight - headerHeight
)
const thumbHeight = clamp(
trackHeight * (trackHeight / bodyContentHeight),
MIN_THUMB_SIZE,
trackHeight
)
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
const thumbTop =
maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0
nextMetrics = {
hasVerticalOverflow: true,
headerHeight,
horizontalScrollbarSize,
thumbHeight,
thumbTop,
trackHeight,
}
}
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
applyMetrics(container, nextMetrics)
metricsRef.current = nextMetrics
}
setHasCustomVerticalOverflow((prev) =>
prev === nextMetrics.hasVerticalOverflow
? prev
: nextMetrics.hasVerticalOverflow
)
}, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar])
useEffect(() => {
const container = containerRef.current
const viewport = viewportRef.current
if (!container || !viewport) return
if (!usesCustomVerticalScrollbar) {
resetMetrics()
return
}
observedElementsRef.current = {
header: container.querySelector(
'[data-slot="data-grid-table"] thead'
) as HTMLElement | null,
horizontalScrollbar: container.querySelector(
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
) as HTMLElement | null,
table: container.querySelector(
'[data-slot="data-grid-table"]'
) as HTMLElement | null,
tableViewport: container.querySelector(
'[data-slot="data-grid-table-viewport"]'
) as HTMLElement | null,
}
let frame = 0
const scheduleSync = () => {
cancelAnimationFrame(frame)
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
}
scheduleSync()
viewport.addEventListener("scroll", scheduleSync, { passive: true })
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(scheduleSync)
observer?.observe(viewport)
observedElementsRef.current.header &&
observer?.observe(observedElementsRef.current.header)
observedElementsRef.current.table &&
observer?.observe(observedElementsRef.current.table)
observedElementsRef.current.tableViewport &&
observer?.observe(observedElementsRef.current.tableViewport)
return () => {
cancelAnimationFrame(frame)
observer?.disconnect()
viewport.removeEventListener("scroll", scheduleSync)
clearDragState()
}
}, [
clearDragState,
resetMetrics,
syncCustomVerticalScrollbar,
usesCustomVerticalScrollbar,
])
const scrollToThumbOffset = (nextThumbTop: number) => {
const viewport = viewportRef.current
const { thumbHeight, trackHeight } = metricsRef.current
if (!viewport) return
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
if (maxScroll === 0 || maxThumbTop === 0) {
viewport.scrollTop = 0
return
}
const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop
viewport.scrollTop = ratio * maxScroll
}
const handleThumbPointerDown = (event: PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current
if (!viewport) return
event.preventDefault()
event.stopPropagation()
event.currentTarget.setPointerCapture(event.pointerId)
dragRef.current = {
pointerId: event.pointerId,
startScrollTop: viewport.scrollTop,
startY: event.clientY,
}
document.body.style.userSelect = "none"
document.body.style.webkitUserSelect = "none"
}
const handleThumbPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current
const dragState = dragRef.current
const { thumbHeight, trackHeight } = metricsRef.current
if (!viewport || !dragState || dragState.pointerId !== event.pointerId) {
return
}
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
if (maxThumbTop === 0 || maxScroll === 0) return
const deltaY = event.clientY - dragState.startY
const nextScrollTop =
dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll
viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll)
}
const handleThumbPointerUp = (event: PointerEvent<HTMLDivElement>) => {
if (dragRef.current?.pointerId !== event.pointerId) return
clearDragState()
}
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
const { thumbHeight } = metricsRef.current
if (event.target !== event.currentTarget) return
event.preventDefault()
event.stopPropagation()
const rect = event.currentTarget.getBoundingClientRect()
const offsetY = event.clientY - rect.top - thumbHeight / 2
scrollToThumbOffset(offsetY)
}
return (
<div ref={containerRef} className="relative">
<ScrollAreaPrimitive.Root
data-slot="data-grid-scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
data-slot="scroll-area-viewport"
className="size-full"
>
<ScrollAreaPrimitive.Content data-slot="scroll-area-content">
{children}
</ScrollAreaPrimitive.Content>
</ScrollAreaPrimitive.Viewport>
{showHorizontal && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="horizontal"
orientation="horizontal"
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className="bg-border rounded-full relative flex-1"
/>
</ScrollAreaPrimitive.Scrollbar>
)}
{showVertical && !usesCustomVerticalScrollbar && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="vertical"
orientation="vertical"
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className="bg-border rounded-full relative flex-1"
/>
</ScrollAreaPrimitive.Scrollbar>
)}
</ScrollAreaPrimitive.Root>
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
>
<div
className="pointer-events-auto relative h-full w-2 touch-none p-px"
onPointerDown={handleTrackPointerDown}
>
<div
className={cn(
"bg-border absolute end-px w-2",
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
"rounded-full"
)}
onLostPointerCapture={clearDragState}
onPointerCancel={handleThumbPointerUp}
onPointerDown={handleThumbPointerDown}
onPointerMove={handleThumbPointerMove}
onPointerUp={handleThumbPointerUp}
/>
</div>
</div>
)}
</div>
)
}
export { DataGridScrollArea }
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
@@ -0,0 +1,309 @@
"use client"
import {
createContext,
CSSProperties,
ReactNode,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
UniqueIdentifier,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { Cell, flexRender, HeaderGroup, Row } from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { GripHorizontalIcon } from "lucide-react"
// Context to share sortable listeners from row to handle
type SortableContextValue = ReturnType<typeof useSortable>
const SortableRowContext = createContext<Pick<
SortableContextValue,
"attributes" | "listeners"
> | null>(null)
function DataGridTableDndRowHandle({ className }: { className?: string }) {
const context = useContext(SortableRowContext)
if (!context) {
// Fallback if context is not available (shouldn't happen in normal usage)
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
disabled
>
<GripHorizontalIcon
/>
</Button>
)
}
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
{...context.attributes}
{...context.listeners}
>
<GripHorizontalIcon
/>
</Button>
)
}
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
const {
transform,
transition,
setNodeRef,
isDragging,
attributes,
listeners,
} = useSortable({
id: row.id,
})
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: transition,
opacity: isDragging ? 0.8 : 1,
zIndex: isDragging ? 1 : 0,
position: "relative",
cursor: isDragging ? "grabbing" : undefined,
}
return (
<SortableRowContext.Provider value={{ attributes, listeners }}>
<DataGridTableBodyRow
row={row}
dndRef={setNodeRef}
dndStyle={style}
key={row.id}
>
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => {
return (
<DataGridTableBodyRowCell cell={cell} key={colIndex}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
})}
</DataGridTableBodyRow>
</SortableRowContext.Provider>
)
}
function DataGridTableDndRows<TData>({
handleDragEnd,
dataIds,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
dataIds: UniqueIdentifier[]
footerContent?: ReactNode
}) {
const { table, isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
const tableContainerRef = useRef<HTMLDivElement>(null)
const [isDraggingRow, setIsDraggingRow] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
useEffect(() => {
if (!isDraggingRow) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingRow])
const modifiers = useMemo(() => {
const restrictToTableContainer: Modifier = ({
transform,
draggingNodeRect,
}) => {
if (!tableContainerRef.current || !draggingNodeRect) {
return transform
}
const containerRect = tableContainerRef.current.getBoundingClientRect()
const { x, y } = transform
const minX = containerRect.left - draggingNodeRect.left
const maxX = containerRect.right - draggingNodeRect.right
const minY = containerRect.top - draggingNodeRect.top
const maxY = containerRect.bottom - draggingNodeRect.bottom
return {
...transform,
x: Math.max(minX, Math.min(maxX, x)),
y: Math.max(minY, Math.min(maxY, y)),
}
}
return [restrictToVerticalAxis, restrictToTableContainer]
}, [])
return (
<DndContext
id={useId()}
collisionDetection={closestCenter}
modifiers={modifiers}
onDragCancel={() => setIsDraggingRow(false)}
onDragEnd={(event) => {
setIsDraggingRow(false)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingRow(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={tableContainerRef}
className={
isDraggingRow
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
{headerGroup.headers.map((header, index) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={index}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
{props.loadingMode === "skeleton" &&
isLoading &&
pagination?.pageSize ? (
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
</DataGridTableBodyRowSkeleton>
))
) : table.getRowModel().rows.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row: Row<TData>) => {
return <DataGridTableDndRow row={row} key={row.id} />
})}
</SortableContext>
) : (
<DataGridTableEmpty />
)}
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDndRowHandle, DataGridTableDndRows }
@@ -0,0 +1,314 @@
"use client"
import {
CSSProperties,
Fragment,
ReactNode,
useEffect,
useId,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
Modifier,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core"
import {
horizontalListSortingStrategy,
SortableContext,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
Cell,
flexRender,
Header,
HeaderGroup,
Row,
} from "@tanstack/react-table"
import { Button } from "@/components/ui/button"
import { GripVerticalIcon } from "lucide-react"
function DataGridTableDndHeader<TData>({
header,
}: {
header: Header<TData, unknown>
}) {
const { props } = useDataGrid()
const { column } = header
// Check if column ordering is enabled for this column
const canOrder =
(column.columnDef as { enableColumnOrdering?: boolean })
.enableColumnOrdering !== false
const {
attributes,
isDragging,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({
id: header.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
whiteSpace: "nowrap",
width: props.tableLayout?.columnsResizable
? `calc(var(--header-${header.id}-size) * 1px)`
: header.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableHeadRowCell
header={header}
dndStyle={style}
dndRef={setNodeRef}
>
<div className="flex items-center justify-start gap-0.5">
{canOrder && (
<Button
size="icon-sm"
variant="ghost"
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
{...attributes}
{...listeners}
aria-label="Drag to reorder"
>
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button>
)}
<span className="grow truncate">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</span>
{props.tableLayout?.columnsResizable && column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</div>
</DataGridTableHeadRowCell>
)
}
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
const { props } = useDataGrid()
const { isDragging, setNodeRef, transform, transition } = useSortable({
id: cell.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
width: props.tableLayout?.columnsResizable
? `calc(var(--col-${cell.column.id}-size) * 1px)`
: cell.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
}
function DataGridTableDnd<TData>({
handleDragEnd,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
footerContent?: ReactNode
}) {
const { table, isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
const containerRef = useRef<HTMLDivElement>(null)
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
useEffect(() => {
if (!isDraggingColumn) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingColumn])
// Custom modifier to restrict dragging within table bounds with edge offset
const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => {
if (!draggingNodeRect || !containerRef.current) {
return { ...transform, y: 0 }
}
const containerRect = containerRef.current.getBoundingClientRect()
const edgeOffset = 0
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
const maxX =
containerRect.right -
draggingNodeRect.left -
draggingNodeRect.width +
edgeOffset
return {
...transform,
x: Math.min(Math.max(transform.x, minX), maxX),
y: 0, // Lock vertical movement
}
}
return (
<DndContext
collisionDetection={closestCenter}
id={useId()}
modifiers={[restrictToTableBounds]}
onDragCancel={() => setIsDraggingColumn(false)}
onDragEnd={(event) => {
setIsDraggingColumn(false)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingColumn(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={containerRef}
className={
isDraggingColumn
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
{headerGroup.headers.map((header) => (
<DataGridTableDndHeader
header={header}
key={header.id}
/>
))}
</SortableContext>
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
{props.loadingMode === "skeleton" &&
isLoading &&
pagination?.pageSize ? (
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
</DataGridTableBodyRowSkeleton>
))
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row: Row<TData>) => {
return (
<Fragment key={row.id}>
<DataGridTableBodyRow row={row}>
{row
.getVisibleCells()
.map((cell: Cell<TData, unknown>) => {
return (
<SortableContext
key={cell.id}
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
<DataGridTableDndCell cell={cell} />
</SortableContext>
)
})}
</DataGridTableBodyRow>
{row.getIsExpanded() && (
<DataGridTableBodyRowExpandded row={row} />
)}
</Fragment>
)
})
) : (
<DataGridTableEmpty />
)}
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDnd }
@@ -0,0 +1,492 @@
"use client"
import {
memo,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRenderedRow,
DataGridTableRowSpacer,
DataGridTableViewport,
getDataGridTableRowSections,
} from "@/components/reui/data-grid/data-grid-table"
import { flexRender, HeaderGroup, Row, Table } from "@tanstack/react-table"
import {
useVirtualizer,
VirtualItem,
Virtualizer,
VirtualizerOptions,
} from "@tanstack/react-virtual"
import { cn } from "@/lib/utils"
import { Spinner } from "@/components/ui/spinner"
type DataGridTableVirtualScrollElements = {
containerElement: HTMLDivElement | null
scrollElement: HTMLElement | null
}
type DataGridTableVirtualizerInstance = Virtualizer<
HTMLElement,
HTMLTableRowElement
>
type DataGridTableVirtualizerOptions<TData> = Omit<
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
> & {
estimateSize?: (index: number, row: Row<TData>) => number
getItemKey?: (index: number, row: Row<TData>) => string | number
getScrollElement?: (
elements: DataGridTableVirtualScrollElements
) => HTMLElement | null
}
interface DataGridTableVirtualProps<TData> {
height?: number | string
estimateSize?: number
overscan?: number
footerContent?: ReactNode
renderHeader?: boolean
onFetchMore?: () => void
isFetchingMore?: boolean
hasMore?: boolean
fetchMoreOffset?: number
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
}
interface VirtualBodyProps<TData> {
table: Table<TData>
columnCount: number
topRows: Row<TData>[]
centerRows: Row<TData>[]
bottomRows: Row<TData>[]
virtualItems: VirtualItem[]
totalSize: number
isVirtualizationEnabled: boolean
isInfiniteMode: boolean
isFetchingMore: boolean
hasMore?: boolean
loadingMoreMessage: ReactNode
allRowsLoadedMessage: ReactNode
measureRowRef?: (element: HTMLTableRowElement | null) => void
}
function DataGridTableVirtualSpacer({
columnCount,
height,
}: {
columnCount: number
height: number
}) {
if (height <= 0) return null
return (
<tr aria-hidden="true">
<td colSpan={columnCount} style={{ height, padding: 0 }} />
</tr>
)
}
function DataGridTableVirtualStatusRow({
children,
className,
columnCount,
}: {
children: ReactNode
className?: string
columnCount: number
}) {
return (
<tr>
<td
colSpan={columnCount}
className={cn(
"text-muted-foreground py-4 text-center text-sm",
className
)}
>
{children}
</td>
</tr>
)
}
function DataGridTableVirtualBody<TData>({
table,
columnCount,
topRows,
centerRows,
bottomRows,
virtualItems,
totalSize,
isVirtualizationEnabled,
isInfiniteMode,
isFetchingMore,
hasMore,
loadingMoreMessage,
allRowsLoadedMessage,
measureRowRef,
}: VirtualBodyProps<TData>) {
const totalRows = topRows.length + centerRows.length + bottomRows.length
if (!totalRows) return <DataGridTableEmpty />
const hasCenterRows = centerRows.length > 0
const showFetchingRow = isInfiniteMode && isFetchingMore
const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0
const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow
const leadingSpacerHeight =
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
? (virtualItems[0]?.start ?? 0)
: 0
const trailingSpacerHeight =
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
? Math.max(
0,
totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)
)
: 0
const renderedRows: ReactNode[] = []
topRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
pinnedBoundary={
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
}
/>
)
})
if (isVirtualizationEnabled) {
if (leadingSpacerHeight > 0) {
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-start"
columnCount={columnCount}
height={leadingSpacerHeight}
/>
)
}
virtualItems.forEach((virtualRow) => {
const row = centerRows[virtualRow.index]
if (!row) return
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
rowRef={measureRowRef}
/>
)
})
if (trailingSpacerHeight > 0) {
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-end"
columnCount={columnCount}
height={trailingSpacerHeight}
/>
)
}
} else {
centerRows.forEach((row) => {
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
})
}
if (showFetchingRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow
key="virtual-status-loading"
columnCount={columnCount}
>
<div className="flex items-center justify-center gap-2">
<Spinner className="size-4 opacity-60" />
{loadingMoreMessage}
</div>
</DataGridTableVirtualStatusRow>
)
}
if (showCompleteRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow
key="virtual-status-complete"
columnCount={columnCount}
className="py-3 text-xs"
>
{allRowsLoadedMessage}
</DataGridTableVirtualStatusRow>
)
}
bottomRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
pinnedBoundary={
index === 0 && (topRows.length > 0 || hasMiddleSection)
? "bottom"
: undefined
}
/>
)
})
return <>{renderedRows}</>
}
/**
* Memoized virtual body: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
*/
const MemoizedVirtualBody = memo(
DataGridTableVirtualBody,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
) as typeof DataGridTableVirtualBody
function DataGridTableVirtual<TData>({
height,
estimateSize = 48,
overscan = 10,
footerContent,
renderHeader = true,
onFetchMore,
isFetchingMore = false,
hasMore,
fetchMoreOffset = 0,
virtualizerOptions,
}: DataGridTableVirtualProps<TData>) {
const { table, props } = useDataGrid()
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
table,
props.tableLayout?.rowsPinnable
)
const columnCount =
table.getVisibleFlatColumns().length +
(props.tableLayout?.columnsResizable ? 1 : 0)
const isInfiniteMode = typeof onFetchMore === "function"
const [viewportElements, setViewportElements] =
useState<DataGridTableVirtualScrollElements>({
containerElement: null,
scrollElement: null,
})
const {
estimateSize: customEstimateSize,
getItemKey: customGetItemKey,
getScrollElement: customGetScrollElement,
measureElement: customMeasureElement,
overscan: customOverscan,
...virtualizerOptionsRest
} = virtualizerOptions ?? {}
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
const loadingMoreMessage =
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
const allRowsLoadedMessage =
props.allRowsLoadedMessage || "All records loaded"
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
setViewportElements({
containerElement: node,
scrollElement:
(node?.closest(
'[data-slot="scroll-area-viewport"]'
) as HTMLElement | null) ?? node,
})
}, [])
const usesExternalScrollArea =
viewportElements.scrollElement !== null &&
viewportElements.scrollElement !== viewportElements.containerElement
const resolveScrollElement = useCallback(() => {
if (customGetScrollElement) {
return customGetScrollElement(viewportElements)
}
return viewportElements.scrollElement
}, [customGetScrollElement, viewportElements])
const resolveItemKey = useCallback(
(index: number) => {
const row = centerRows[index]
if (!row) return index
return customGetItemKey?.(index, row) ?? row.id ?? index
},
[centerRows, customGetItemKey]
)
const resolveEstimateSize = useCallback(
(index: number) => {
const row = centerRows[index]
return row
? (customEstimateSize?.(index, row) ?? estimateSize)
: estimateSize
},
[centerRows, customEstimateSize, estimateSize]
)
const virtualizer = useVirtualizer({
count: centerRows.length,
getScrollElement: resolveScrollElement,
getItemKey: resolveItemKey,
estimateSize: resolveEstimateSize,
overscan: customOverscan ?? overscan,
measureElement: customMeasureElement,
...virtualizerOptionsRest,
}) as DataGridTableVirtualizerInstance
const virtualItems = isVirtualizationEnabled
? virtualizer.getVirtualItems()
: []
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
const measureRowRef =
isVirtualizationEnabled && customMeasureElement
? virtualizer.measureElement
: undefined
const resolvedFetchMoreOffset = useMemo(
() => Math.max(0, fetchMoreOffset),
[fetchMoreOffset]
)
useEffect(() => {
if (
!isVirtualizationEnabled ||
!isInfiniteMode ||
hasMore === false ||
isFetchingMore
) {
return
}
const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
onFetchMore?.()
}
}, [
centerRows.length,
hasMore,
isFetchingMore,
isInfiniteMode,
isVirtualizationEnabled,
onFetchMore,
resolvedFetchMoreOffset,
virtualItems,
])
return (
<DataGridTableViewport
viewportRef={handleViewportRef}
className={!usesExternalScrollArea ? "block" : undefined}
style={
usesExternalScrollArea
? undefined
: { height, overflow: "auto", position: "relative" }
}
>
<DataGridTableBase>
{renderHeader && (
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
{headerGroup.headers.map((header, hIndex) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={hIndex}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
</DataGridTableHeadRow>
))}
</DataGridTableHead>
)}
{renderHeader &&
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedVirtualBody
table={table}
columnCount={columnCount}
topRows={topRows}
centerRows={centerRows}
bottomRows={bottomRows}
virtualItems={virtualItems}
totalSize={totalSize}
isVirtualizationEnabled={isVirtualizationEnabled}
isInfiniteMode={isInfiniteMode}
isFetchingMore={isFetchingMore}
hasMore={hasMore}
loadingMoreMessage={loadingMoreMessage}
allRowsLoadedMessage={allRowsLoadedMessage}
measureRowRef={measureRowRef}
/>
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
)
}
export { DataGridTableVirtual }
export type {
DataGridTableVirtualProps,
DataGridTableVirtualScrollElements,
DataGridTableVirtualizerOptions,
}
File diff suppressed because it is too large Load Diff
+270
View File
@@ -0,0 +1,270 @@
"use client"
import { createContext, ReactNode, useContext, useMemo } from "react"
import {
Column,
ColumnFiltersState,
RowData,
SortingState,
Table,
} from "@tanstack/react-table"
import { cn } from "@/lib/utils"
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
headerTitle?: string
headerClassName?: string
cellClassName?: string
skeleton?: ReactNode
expandedContent?: (row: TData) => ReactNode
}
}
/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */
export function getColumnHeaderLabel<TData, TValue>(
column: Column<TData, TValue>
): string {
const meta = column.columnDef.meta as { headerTitle?: string } | undefined
if (typeof meta?.headerTitle === "string") return meta.headerTitle
const defHeader = column.columnDef.header
if (typeof defHeader === "string") return defHeader
return String(column.id)
}
export type DataGridApiFetchParams = {
pageIndex: number
pageSize: number
sorting?: SortingState
filters?: ColumnFiltersState
searchQuery?: string
}
export type DataGridApiResponse<T> = {
data: T[]
empty: boolean
pagination: {
total: number
page: number
}
}
export interface DataGridContextProps<TData extends object> {
props: DataGridProps<TData>
table: Table<TData>
recordCount: number
isLoading: boolean
}
export type DataGridRequestParams = {
pageIndex: number
pageSize: number
sorting?: SortingState
columnFilters?: ColumnFiltersState
}
export interface DataGridProps<TData extends object> {
className?: string
table?: Table<TData>
recordCount: number
children?: ReactNode
onRowClick?: (row: TData) => void
isLoading?: boolean
loadingMode?: "skeleton" | "spinner"
loadingMessage?: ReactNode | string
fetchingMoreMessage?: ReactNode | string
allRowsLoadedMessage?: ReactNode | string
emptyMessage?: ReactNode | string
tableLayout?: {
dense?: boolean
cellBorder?: boolean
rowBorder?: boolean
rowRounded?: boolean
stripped?: boolean
headerBackground?: boolean
headerBorder?: boolean
headerSticky?: boolean
width?: "auto" | "fixed"
columnsVisibility?: boolean
columnsResizable?: boolean
columnsResizeMode?: "onChange" | "onEnd"
columnsPinnable?: boolean
columnsMovable?: boolean
columnsDraggable?: boolean
rowsDraggable?: boolean
rowsPinnable?: boolean
}
tableClassNames?: {
base?: string
header?: string
headerRow?: string
headerSticky?: string
body?: string
bodyRow?: string
footer?: string
edgeCell?: string
}
}
const DataGridContext = createContext<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
DataGridContextProps<any> | undefined
>(undefined)
function useDataGrid() {
const context = useContext(DataGridContext)
if (!context) {
throw new Error("useDataGrid must be used within a DataGridProvider")
}
return context
}
function DataGridProvider<TData extends object>({
children,
table,
...props
}: DataGridProps<TData> & { table: Table<TData> }) {
const tableState = table.getState()
const resolvedColumnsResizeMode =
props.tableLayout?.columnsResizeMode ?? "onEnd"
// Keep resize mode aligned with the DataGrid contract every render so
// consumer-level useReactTable options cannot flip it back between drags.
if (props.tableLayout?.columnsResizable) {
table.options.columnResizeMode = resolvedColumnsResizeMode
}
// Memoize context value so consumers don't re-render during column resize.
// Column sizing state is intentionally excluded from deps -- CSS variables
// on the <table> element handle width updates without React re-renders.
const value = useMemo(
() => ({
props,
table,
recordCount: props.recordCount,
isLoading: props.isLoading || false,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
table,
props.recordCount,
props.isLoading,
props.loadingMode,
props.loadingMessage,
props.fetchingMoreMessage,
props.allRowsLoadedMessage,
props.emptyMessage,
props.onRowClick,
props.className,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(props.tableLayout),
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(props.tableClassNames),
tableState.sorting,
tableState.pagination,
tableState.columnFilters,
tableState.rowSelection,
tableState.expanded,
tableState.columnVisibility,
tableState.columnOrder,
tableState.columnPinning,
tableState.globalFilter,
]
)
return (
<DataGridContext.Provider value={value}>
{children}
</DataGridContext.Provider>
)
}
function DataGrid<TData extends object>({
children,
table,
...props
}: DataGridProps<TData>) {
const defaultProps: Partial<DataGridProps<TData>> = {
loadingMode: "skeleton",
tableLayout: {
dense: false,
cellBorder: false,
rowBorder: true,
rowRounded: false,
stripped: false,
headerSticky: false,
headerBackground: true,
headerBorder: true,
width: "fixed",
columnsVisibility: false,
columnsResizable: false,
columnsResizeMode: "onEnd",
columnsPinnable: false,
columnsMovable: false,
columnsDraggable: false,
rowsDraggable: false,
rowsPinnable: false,
},
tableClassNames: {
base: "",
header: "",
headerRow: "",
headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs",
body: "",
bodyRow: "",
footer: "",
edgeCell: "",
},
}
const mergedProps: DataGridProps<TData> = {
...defaultProps,
...props,
tableLayout: {
...defaultProps.tableLayout,
...(props.tableLayout || {}),
},
tableClassNames: {
...defaultProps.tableClassNames,
...(props.tableClassNames || {}),
},
}
// Ensure table is provided
if (!table) {
throw new Error('DataGrid requires a "table" prop')
}
return (
<DataGridProvider table={table} {...mergedProps}>
{children}
</DataGridProvider>
)
}
function DataGridContainer({
children,
className,
border = true,
}: {
children: ReactNode
className?: string
border?: boolean
}) {
return (
<div
data-slot="data-grid"
className={cn(
"w-full overflow-hidden",
border &&
"border-border rounded-lg border",
className
)}
>
{children}
</div>
)
}
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
+36
View File
@@ -0,0 +1,36 @@
export {
DataGrid,
DataGridContainer,
DataGridProvider,
useDataGrid,
getColumnHeaderLabel,
type DataGridProps,
type DataGridApiFetchParams,
type DataGridApiResponse,
} from "./data-grid"
export { DataGridColumnFilter } from "./data-grid-column-filter"
export { DataGridColumnHeader } from "./data-grid-column-header"
export { DataGridColumnVisibility } from "./data-grid-column-visibility"
export { DataGridPagination } from "./data-grid-pagination"
export { DataGridScrollArea } from "./data-grid-scroll-area"
export { DataGridTableDndRowHandle, DataGridTableDndRows } from "./data-grid-table-dnd-rows"
export { DataGridTableDnd } from "./data-grid-table-dnd"
export { DataGridTableVirtual } from "./data-grid-table-virtual"
export {
DataGridTable,
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableFootRow,
DataGridTableFootRowCell,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableRowSelect,
DataGridTableRowSelectAll,
getDataGridTableRowSections,
} from "./data-grid-table"
File diff suppressed because it is too large Load Diff
+170
View File
@@ -0,0 +1,170 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
/**
* CSS variable architecture for FramePanel theming:
*
* The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
* FramePanel consumes them directly via bg-(--frame-panel-bg) and
* border-(--frame-panel-border-color). This means:
*
* - variant="inverse" overrides those vars on Frame all panels pick it up
* - <FramePanel className="bg-blue-50"> adds a direct utility on the element
* which wins over bg-(--frame-panel-bg) by Tailwind source order no
* :not() or !important needed
*/
const frameVariants = cva(
[
"relative flex flex-col bg-muted/50 gap-0.75 p-0.75 rounded-(--frame-radius)",
"[--frame-radius:var(--radius-xl)]",
// Default panel token values — overridden per-variant below
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
],
{
variants: {
variant: {
default: "border border-[var(--frame-border-color)] bg-clip-padding",
inverse:
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
ghost: "",
},
spacing: {
xs: "[--frame-panel-p:--spacing(2)] [--frame-panel-header-px:--spacing(2)] [--frame-panel-header-py:--spacing(1)] [--frame-panel-footer-px:--spacing(2)] [--frame-panel-footer-py:--spacing(1)]",
sm: "[--frame-panel-p:--spacing(3)] [--frame-panel-header-px:--spacing(3)] [--frame-panel-header-py:--spacing(2)] [--frame-panel-footer-px:--spacing(3)] [--frame-panel-footer-py:--spacing(2)]",
default:
"[--frame-panel-p:--spacing(4)] [--frame-panel-header-px:--spacing(4)] [--frame-panel-header-py:--spacing(3)] [--frame-panel-footer-px:--spacing(4)] [--frame-panel-footer-py:--spacing(3)]",
lg: "[--frame-panel-p:--spacing(5)] [--frame-panel-header-px:--spacing(5)] [--frame-panel-header-py:--spacing(4)] [--frame-panel-footer-px:--spacing(5)] [--frame-panel-footer-py:--spacing(4)]",
},
stacked: {
true: [
"gap-0 *:has-[+[data-slot=frame-panel]]:rounded-b-none",
"*:has-[+[data-slot=frame-panel]]:before:hidden",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:rounded-t-none",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:border-t-0",
// No FrameHeader present: first panel sits flush against the outer frame border
"[&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:border-t-0",
],
false: [
"data-[spacing=sm]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-0.5",
"data-[spacing=default]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-1",
"data-[spacing=lg]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-2",
],
},
dense: {
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars
true: "p-0 gap-0 border-[var(--frame-border-color)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px",
false: "",
},
},
defaultVariants: {
variant: "default",
spacing: "default",
stacked: false,
dense: false,
},
}
)
function Frame({
className,
variant,
spacing,
stacked,
dense,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof frameVariants>) {
return (
<div
className={cn(
frameVariants({ variant, spacing, stacked, dense }),
className
)}
data-slot="frame"
data-spacing={spacing}
{...props}
/>
)
}
function FramePanel({
className,
fit,
...props
}: React.ComponentProps<"div"> & { fit?: boolean }) {
return (
<div
className={cn(
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
// via className overrides these by Tailwind source order — no ! needed.
"relative grow overflow-hidden rounded-(--frame-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-radius)-1px)] before:shadow-black/5",
"dark:bg-clip-border dark:before:shadow-white/5",
"p-(--frame-panel-p)",
className
)}
data-slot="frame-panel"
{...props}
/>
)
}
function FrameHeader({ className, ...props }: React.ComponentProps<"header">) {
return (
<header
className={cn(
"flex flex-col px-(--frame-panel-header-px) py-(--frame-panel-header-py)",
className
)}
data-slot="frame-panel-header"
{...props}
/>
)
}
function FrameTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("text-sm font-semibold", className)}
data-slot="frame-panel-title"
{...props}
/>
)
}
function FrameDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn("text-muted-foreground text-sm", className)}
data-slot="frame-panel-description"
{...props}
/>
)
}
function FrameFooter({ className, ...props }: React.ComponentProps<"footer">) {
return (
<footer
className={cn(
"flex flex-col gap-1 px-(--frame-panel-footer-px) py-(--frame-panel-footer-py)",
className
)}
data-slot="frame-panel-footer"
{...props}
/>
)
}
export {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
FrameFooter,
frameVariants,
}

Some files were not shown because too many files have changed in this diff Show More