Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3a2d38b37 | ||
|
|
a1a9124f3d | ||
|
|
009011a917 | ||
|
|
399871f4f9 |
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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
|
```powershell
|
||||||
npm run dev
|
npm run dev
|
||||||
npm --prefix backend run dev
|
npm --prefix backend run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
- Frontend: `http://localhost:3000`
|
| Сервис | URL | Проверка |
|
||||||
- Backend: `http://localhost:8000`
|
|--------|-----|----------|
|
||||||
- Health check backend: `http://localhost:8000/health`
|
| 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`, раздел «Запуск».
|
||||||
|
|||||||
@@ -10,3 +10,7 @@
|
|||||||
4. Локальный hook `.githooks/commit-msg` отклоняет subject без кириллицы; подключение — `npm install` / `npm run prepare`.
|
4. Локальный hook `.githooks/commit-msg` отклоняет subject без кириллицы; подключение — `npm install` / `npm run prepare`.
|
||||||
|
|
||||||
Полные правила: `.cursor/rules/commit-messages-ru.mdc`, semver — `.cursor/rules/release-versioning.mdc`.
|
Полные правила: `.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`.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
@@ -685,23 +686,6 @@ const INIT_TG: TelegramConfig = {
|
|||||||
|
|
||||||
// ─── small helpers ────────────────────────────────────────────────────────────
|
// ─── 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 }) {
|
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>
|
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",
|
!rule.enabled && "opacity-55",
|
||||||
)}>
|
)}>
|
||||||
{/* toggle */}
|
{/* 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 */}
|
{/* severity dot */}
|
||||||
<SeverityDot severity={rule.severity} />
|
<SeverityDot severity={rule.severity} />
|
||||||
|
|||||||
+44
-51
@@ -1,18 +1,24 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
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 { asns as mockAsns } from "@/lib/data"
|
||||||
import { Button } from "@/components/ui/button"
|
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 { useDataSource } from "@/lib/data-source"
|
||||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function AsnsPage() {
|
export default function AsnsPage() {
|
||||||
const { mode } = useDataSource()
|
const { mode } = useDataSource()
|
||||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||||
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
const useEvoCatalog = mode === "live" && enabled
|
const useEvoCatalog = mode === "live" && enabled
|
||||||
|
|
||||||
@@ -22,13 +28,26 @@ export default function AsnsPage() {
|
|||||||
return snapshot?.asns ?? []
|
return snapshot?.asns ?? []
|
||||||
}, [useEvoCatalog, loading, snapshot])
|
}, [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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
|
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
|
||||||
actions={
|
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 variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||||
<Button size="sm"><PlusIcon className="size-4" />Добавить ASN</Button>
|
<Button size="sm"><PlusIcon className="size-4" />Добавить ASN</Button>
|
||||||
</>
|
</>
|
||||||
@@ -55,57 +74,31 @@ export default function AsnsPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataPageCard>
|
||||||
data={rows}
|
<DataPageToolbar
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder="Поиск по ASN, имени, префиксам…"
|
searchPlaceholder="Поиск по ASN, имени, префиксам…"
|
||||||
searchKeys={["asn", "org", "prefixes"]}
|
countLabel={`${filtered.length} ASN`}
|
||||||
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>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
<AsnsDataGrid
|
||||||
|
asns={filtered}
|
||||||
|
isLoading={useEvoCatalog && loading && !snapshot}
|
||||||
|
pagination={useEvoCatalog}
|
||||||
|
/>
|
||||||
|
</DataPageCard>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-159
@@ -2,9 +2,14 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { 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 { StatusBadge } from "@/components/status-badge"
|
||||||
import type { Backup, Server } from "@/lib/data"
|
import type { Backup, Server } from "@/lib/data"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
@@ -22,52 +27,17 @@ import { listServers } from "@/shared/api/servers"
|
|||||||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||||
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
|
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
// ─── small UI helpers ─────────────────────────────────────────────────────────
|
Stepper,
|
||||||
|
StepperContent,
|
||||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
StepperIndicator,
|
||||||
return (
|
StepperItem,
|
||||||
<button type="button" role="switch" aria-checked={checked} onClick={() => onChange(!checked)}
|
StepperNav,
|
||||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors ${checked ? "bg-primary" : "bg-input"}`}>
|
StepperPanel,
|
||||||
<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"}`} />
|
StepperSeparator,
|
||||||
</button>
|
StepperTitle,
|
||||||
)
|
StepperTrigger,
|
||||||
}
|
} from "@/components/reui/stepper"
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -168,8 +138,11 @@ export default function BackupsPage() {
|
|||||||
|
|
||||||
// Manual backup sheet
|
// Manual backup sheet
|
||||||
const [manualOpen, setManualOpen] = useState(false)
|
const [manualOpen, setManualOpen] = useState(false)
|
||||||
|
const [manualStep, setManualStep] = useState(1)
|
||||||
const [manualServers, setManualServers] = useState<Set<string>>(new Set())
|
const [manualServers, setManualServers] = useState<Set<string>>(new Set())
|
||||||
const [manualNotes, setManualNotes] = useState("")
|
const [manualNotes, setManualNotes] = useState("")
|
||||||
|
const [restoreOpen, setRestoreOpen] = useState(false)
|
||||||
|
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null)
|
||||||
function toggleManualServer(id: string) {
|
function toggleManualServer(id: string) {
|
||||||
setManualServers((prev) => {
|
setManualServers((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@@ -356,7 +329,7 @@ export default function BackupsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualOpen(true) }}
|
onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualStep(1); setManualOpen(true) }}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-4" />Новый бэкап
|
<PlusIcon className="size-4" />Новый бэкап
|
||||||
@@ -424,83 +397,29 @@ export default function BackupsPage() {
|
|||||||
|
|
||||||
{/* ── История ──────────────────────────────────────────────────── */}
|
{/* ── История ──────────────────────────────────────────────────── */}
|
||||||
{tab === "history" && (
|
{tab === "history" && (
|
||||||
<Card>
|
<DataPageCard>
|
||||||
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
<DataPageToolbar
|
||||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
segmented={{
|
||||||
{([
|
value: kindFilter,
|
||||||
|
onChange: setKindFilter,
|
||||||
|
options: [
|
||||||
{ value: "all", label: "Все", count: backupList.length },
|
{ value: "all", label: "Все", count: backupList.length },
|
||||||
{ value: "auto", label: "Авто", count: autoCount },
|
{ value: "auto", label: "Авто", count: autoCount },
|
||||||
{ value: "manual", label: "Вручную", count: manualCount },
|
{ 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"}`}>
|
countLabel={`${filtered.length} бэкапов`}
|
||||||
{t.label}
|
/>
|
||||||
<span className="text-xs tabular-nums opacity-60">{t.count}</span>
|
<BackupsDataGrid
|
||||||
</button>
|
backups={filtered}
|
||||||
))}
|
onDownload={handleDownload}
|
||||||
</div>
|
onRestore={(b) => {
|
||||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} бэкапов</span>
|
setRestoreTarget(b)
|
||||||
</div>
|
setRestoreOpen(true)
|
||||||
|
}}
|
||||||
<div className="overflow-x-auto">
|
onDelete={handleDelete}
|
||||||
<table className="w-full text-sm">
|
/>
|
||||||
<thead>
|
</DataPageCard>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Настройки ────────────────────────────────────────────────── */}
|
{/* ── Настройки ────────────────────────────────────────────────── */}
|
||||||
@@ -517,11 +436,11 @@ export default function BackupsPage() {
|
|||||||
<p className="text-sm font-medium">Автоматический бэкап</p>
|
<p className="text-sm font-medium">Автоматический бэкап</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
|
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={schedule.enabled} onChange={(v) => setSched("enabled", v)} />
|
<FormToggle checked={schedule.enabled} onChange={(v) => setSched("enabled", v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn("flex flex-col gap-4 transition-opacity", !schedule.enabled && "opacity-40 pointer-events-none")}>
|
<div className={cn("flex flex-col gap-4 transition-opacity", !schedule.enabled && "opacity-40 pointer-events-none")}>
|
||||||
<Field label="Частота">
|
<FormField label="Частота">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={schedule.frequency}
|
value={schedule.frequency}
|
||||||
onChange={(v) => setSched("frequency", v)}
|
onChange={(v) => setSched("frequency", v)}
|
||||||
@@ -531,10 +450,10 @@ export default function BackupsPage() {
|
|||||||
{ value: "monthly", label: "Ежемесячно" },
|
{ value: "monthly", label: "Ежемесячно" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
{schedule.frequency === "weekly" && (
|
{schedule.frequency === "weekly" && (
|
||||||
<Field label="День недели">
|
<FormField label="День недели">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{WEEK_DAYS.map((d, i) => (
|
{WEEK_DAYS.map((d, i) => (
|
||||||
<button key={i} type="button" onClick={() => setSched("weekDay", i)}
|
<button key={i} type="button" onClick={() => setSched("weekDay", i)}
|
||||||
@@ -548,18 +467,18 @@ export default function BackupsPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{schedule.frequency === "monthly" && (
|
{schedule.frequency === "monthly" && (
|
||||||
<Field label="День месяца" hint="1–28">
|
<FormField label="День месяца" hint="1–28">
|
||||||
<Input type="number" min={1} max={28} className="font-mono w-24"
|
<Input type="number" min={1} max={28} className="font-mono w-24"
|
||||||
value={schedule.monthDay}
|
value={schedule.monthDay}
|
||||||
onChange={(e) => setSched("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))} />
|
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="flex items-center gap-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input type="number" min={0} max={23} className="font-mono w-20 text-center"
|
<Input type="number" min={0} max={23} className="font-mono w-20 text-center"
|
||||||
@@ -581,15 +500,15 @@ export default function BackupsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field label="Хранить бэкапов" hint="На каждый сервер">
|
<FormField label="Хранить бэкапов" hint="На каждый сервер">
|
||||||
<Input type="number" min={1} max={90} className="font-mono"
|
<Input type="number" min={1} max={90} className="font-mono"
|
||||||
value={schedule.keepCount}
|
value={schedule.keepCount}
|
||||||
onChange={(e) => setSched("keepCount", Math.max(1, Number(e.target.value)))} />
|
onChange={(e) => setSched("keepCount", Math.max(1, Number(e.target.value)))} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Формат файла">
|
<FormField label="Формат файла">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={schedule.format}
|
value={schedule.format}
|
||||||
onChange={(v) => setSched("format", v)}
|
onChange={(v) => setSched("format", v)}
|
||||||
@@ -598,7 +517,7 @@ export default function BackupsPage() {
|
|||||||
{ value: "backup", label: ".backup" },
|
{ value: "backup", label: ".backup" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -609,7 +528,7 @@ export default function BackupsPage() {
|
|||||||
<CardContent className="px-5 py-5 flex flex-col gap-5">
|
<CardContent className="px-5 py-5 flex flex-col gap-5">
|
||||||
<SectionTitle icon={<FolderIcon className="size-3.5" />}>Хранилище</SectionTitle>
|
<SectionTitle icon={<FolderIcon className="size-3.5" />}>Хранилище</SectionTitle>
|
||||||
|
|
||||||
<Field label="Тип хранилища">
|
<FormField label="Тип хранилища">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={storage.type}
|
value={storage.type}
|
||||||
onChange={(v) => setStore("type", v)}
|
onChange={(v) => setStore("type", v)}
|
||||||
@@ -620,45 +539,45 @@ export default function BackupsPage() {
|
|||||||
{ value: "smb", label: "SMB" },
|
{ value: "smb", label: "SMB" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
{storage.type === "local" && (
|
{storage.type === "local" && (
|
||||||
<Field label="Путь сохранения" hint="Директория на сервере приложения">
|
<FormField label="Путь сохранения" hint="Директория на сервере приложения">
|
||||||
<Input className="font-mono" placeholder="/var/backup/mikrotik"
|
<Input className="font-mono" placeholder="/var/backup/mikrotik"
|
||||||
value={storage.localPath}
|
value={storage.localPath}
|
||||||
onChange={(e) => setStore("localPath", e.target.value)} />
|
onChange={(e) => setStore("localPath", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{storage.type !== "local" && (
|
{storage.type !== "local" && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<Field label="Хост">
|
<FormField label="Хост">
|
||||||
<Input className="font-mono" placeholder="192.168.1.100"
|
<Input className="font-mono" placeholder="192.168.1.100"
|
||||||
value={storage.host} onChange={(e) => setStore("host", e.target.value)} />
|
value={storage.host} onChange={(e) => setStore("host", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<Field label="Порт">
|
<FormField label="Порт">
|
||||||
<Input className="font-mono"
|
<Input className="font-mono"
|
||||||
placeholder={storage.type === "ftp" ? "21" : storage.type === "scp" ? "22" : "445"}
|
placeholder={storage.type === "ftp" ? "21" : storage.type === "scp" ? "22" : "445"}
|
||||||
value={storage.port} onChange={(e) => setStore("port", e.target.value)} />
|
value={storage.port} onChange={(e) => setStore("port", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{storage.type === "smb" && (
|
{storage.type === "smb" && (
|
||||||
<Field label="Общая папка (Share)">
|
<FormField label="Общая папка (Share)">
|
||||||
<Input className="font-mono" placeholder="backups"
|
<Input className="font-mono" placeholder="backups"
|
||||||
value={storage.share} onChange={(e) => setStore("share", e.target.value)} />
|
value={storage.share} onChange={(e) => setStore("share", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Пользователь">
|
<FormField label="Пользователь">
|
||||||
<Input className="font-mono" placeholder="backup-user"
|
<Input className="font-mono" placeholder="backup-user"
|
||||||
value={storage.username} onChange={(e) => setStore("username", e.target.value)} />
|
value={storage.username} onChange={(e) => setStore("username", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label={storage.type === "scp" ? "Пароль / ключ" : "Пароль"}>
|
<FormField label={storage.type === "scp" ? "Пароль / ключ" : "Пароль"}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
type={storage.showPassword ? "text" : "password"}
|
type={storage.showPassword ? "text" : "password"}
|
||||||
@@ -673,13 +592,13 @@ export default function BackupsPage() {
|
|||||||
{storage.showPassword ? "скрыть" : "показ"}
|
{storage.showPassword ? "скрыть" : "показ"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Удалённый путь">
|
<FormField label="Удалённый путь">
|
||||||
<Input className="font-mono" placeholder="/mikrotik-backups"
|
<Input className="font-mono" placeholder="/mikrotik-backups"
|
||||||
value={storage.remotePath} onChange={(e) => setStore("remotePath", e.target.value)} />
|
value={storage.remotePath} onChange={(e) => setStore("remotePath", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -759,15 +678,38 @@ export default function BackupsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ══ Sheet: Manual backup ══════════════════════════════════════════════ */}
|
{/* ══ 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">
|
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
<SheetTitle>Новый бэкап</SheetTitle>
|
<SheetTitle>Новый бэкап</SheetTitle>
|
||||||
<SheetDescription>Снять конфигурацию вручную с выбранных серверов</SheetDescription>
|
<SheetDescription>Снять конфигурацию вручную с выбранных серверов</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
<Stepper value={manualStep} onValueChange={setManualStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||||
<div className="flex flex-col gap-3">
|
<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">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<p className="text-sm font-medium">Выберите серверы</p>
|
<p className="text-sm font-medium">Выберите серверы</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -805,25 +747,60 @@ export default function BackupsPage() {
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</StepperContent>
|
||||||
|
<StepperContent value={2} className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<FormField label="Заметка">
|
||||||
<label className="text-sm font-medium">Заметка</label>
|
|
||||||
<Input placeholder="Например: перед обновлением BGP"
|
<Input placeholder="Например: перед обновлением BGP"
|
||||||
value={manualNotes} onChange={(e) => setManualNotes(e.target.value)} />
|
value={manualNotes} onChange={(e) => setManualNotes(e.target.value)} />
|
||||||
</div>
|
</FormField>
|
||||||
</div>
|
</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">
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||||||
|
{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"
|
<Button className="flex-1"
|
||||||
disabled={manualServers.size === 0 || backupJobId !== null}
|
disabled={manualServers.size === 0 || backupJobId !== null}
|
||||||
onClick={handleManualBackup}>
|
onClick={handleManualBackup}>
|
||||||
Снять бэкап ({manualServers.size})
|
Снять бэкап ({manualServers.size})
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-281
@@ -2,7 +2,15 @@
|
|||||||
|
|
||||||
import { Fragment, useState, useMemo, useEffect } from "react"
|
import { Fragment, useState, useMemo, useEffect } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
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 { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
@@ -17,49 +25,12 @@ import { useDataSource } from "@/lib/data-source"
|
|||||||
|
|
||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type BgpState = "Established" | "Active" | "Idle" | "Connect" | "OpenSent" | "OpenConfirm"
|
type BgpSession = BgpSessionRow
|
||||||
type BgpType = "eBGP" | "iBGP"
|
|
||||||
type BgpAfi = "IPv4 Unicast" | "IPv6 Unicast" | "VPNv4 Unicast"
|
|
||||||
type BgpTab = "sessions" | "routers" | "analytics"
|
type BgpTab = "sessions" | "routers" | "analytics"
|
||||||
type StateFilter = "all" | BgpState
|
type StateFilter = "all" | BgpState
|
||||||
type TypeFilter = "all" | BgpType
|
type TypeFilter = "all" | BgpType
|
||||||
|
|
||||||
interface BgpSession {
|
const AS_NAMES = BGP_AS_NAMES
|
||||||
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",
|
|
||||||
}
|
|
||||||
// ─── mock data ────────────────────────────────────────────────────────────────
|
// ─── mock data ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SESSIONS: BgpSession[] = [
|
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) {
|
function fmtNum(n: number) {
|
||||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||||
@@ -254,108 +218,6 @@ function fmtNum(n: number) {
|
|||||||
return String(n)
|
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 ──────────────────────────────────────────────────────────
|
// ─── backend mapping ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface BackendBgpSession {
|
interface BackendBgpSession {
|
||||||
@@ -397,159 +259,82 @@ function backendToFrontend(b: BackendBgpSession): BgpSession {
|
|||||||
|
|
||||||
// ─── sessions tab ─────────────────────────────────────────────────────────────
|
// ─── sessions tab ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const STATE_FILTERS: Array<{ value: StateFilter; label: string }> = [
|
const BGP_FILTER_ACCESSORS = {
|
||||||
{ value: "all", label: "Все" },
|
state: (s: BgpSession) => s.state,
|
||||||
{ value: "Established", label: "Established" },
|
type: (s: BgpSession) => s.type,
|
||||||
{ value: "Active", label: "Active" },
|
afi: (s: BgpSession) => s.afi,
|
||||||
{ value: "Idle", label: "Idle" },
|
}
|
||||||
{ value: "OpenSent", label: "OpenSent" },
|
|
||||||
]
|
|
||||||
|
|
||||||
function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [stateFilter, setStateFilter] = useState<StateFilter>("all")
|
const [stateFilter, setStateFilter] = useState<StateFilter>("all")
|
||||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
|
||||||
|
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
const filtered = useMemo(() => sessions.filter(s => {
|
const filtered = useMemo(() => {
|
||||||
|
const base = sessions.filter((s) => {
|
||||||
if (stateFilter !== "all" && s.state !== stateFilter) return false
|
if (stateFilter !== "all" && s.state !== stateFilter) return false
|
||||||
if (typeFilter !== "all" && s.type !== typeFilter) return false
|
if (typeFilter !== "all" && s.type !== typeFilter) return false
|
||||||
if (q && !s.peerIp.includes(q) && !s.description.toLowerCase().includes(q)
|
if (
|
||||||
&& !s.serverLabel.includes(q) && !String(s.remoteAs).includes(q)
|
q &&
|
||||||
&& !(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)) return false
|
!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 true
|
||||||
}), [sessions, q, stateFilter, typeFilter])
|
})
|
||||||
|
return applyReuiFilters(base, advancedFilters, BGP_FILTER_ACCESSORS)
|
||||||
|
}, [sessions, q, stateFilter, typeFilter, advancedFilters])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
<DataPageCard>
|
||||||
{/* filter bar */}
|
<DataPageToolbar
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
segmented={{
|
||||||
{/* search */}
|
value: stateFilter,
|
||||||
<div className="relative">
|
onChange: setStateFilter,
|
||||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none z-10" />
|
options: [
|
||||||
<Input
|
{ value: "all", label: "Все", count: sessions.length },
|
||||||
value={search} onChange={e => setSearch(e.target.value)}
|
{ value: "Established", label: "Established", count: sessions.filter((s) => s.state === "Established").length },
|
||||||
placeholder="IP, AS, описание…"
|
{ value: "Active", label: "Active", count: sessions.filter((s) => s.state === "Active").length },
|
||||||
className="h-8 pl-8 pr-8 w-52 text-xs"
|
{ value: "Idle", label: "Idle", count: sessions.filter((s) => s.state === "Idle").length },
|
||||||
/>
|
{ value: "OpenSent", label: "OpenSent", count: sessions.filter((s) => s.state === "OpenSent").length },
|
||||||
{search && (
|
],
|
||||||
<button onClick={() => setSearch("")}
|
}}
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground z-10">
|
filters={advancedFilters}
|
||||||
<XIcon className="size-3" />
|
onFiltersChange={setAdvancedFilters}
|
||||||
</button>
|
filterFields={BGP_FILTER_FIELDS}
|
||||||
)}
|
search={search}
|
||||||
</div>
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder="IP, AS, описание…"
|
||||||
{/* state filter */}
|
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">
|
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
|
||||||
{STATE_FILTERS.map(f => (
|
{(["all", "eBGP", "iBGP"] as const).map((t) => (
|
||||||
<button key={f.value} onClick={() => setStateFilter(f.value)}
|
<button
|
||||||
className={cn(
|
key={t}
|
||||||
"px-2.5 py-1 text-[11px] rounded transition-colors whitespace-nowrap",
|
type="button"
|
||||||
stateFilter === f.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
onClick={() => setTypeFilter(t)}
|
||||||
)}>
|
|
||||||
{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(
|
className={cn(
|
||||||
"px-2.5 py-1 text-[11px] rounded transition-colors",
|
"px-2.5 py-1 text-[11px] rounded transition-colors",
|
||||||
typeFilter === t ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
typeFilter === t
|
||||||
)}>
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{t === "all" ? "Все типы" : t}
|
{t === "all" ? "Все типы" : t}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<span className="text-xs text-muted-foreground ml-auto">
|
/>
|
||||||
{filtered.length} из {sessions.length}
|
<BgpSessionsDataGrid sessions={filtered} />
|
||||||
</span>
|
</DataPageCard>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-392
@@ -1,12 +1,15 @@
|
|||||||
"use client"
|
"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 { 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 { routerCertificates, servers as mockServers } from "@/lib/data"
|
||||||
import type { CertStatus, Server } from "@/lib/data"
|
import type { CertStatus, Server } from "@/lib/data"
|
||||||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
||||||
import { Flag } from "@/components/flag"
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
@@ -35,117 +38,24 @@ import { toast } from "sonner"
|
|||||||
import {
|
import {
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
ShieldAlertIcon,
|
|
||||||
ShieldOffIcon,
|
|
||||||
BadgeCheckIcon,
|
BadgeCheckIcon,
|
||||||
AlertTriangleIcon,
|
AlertTriangleIcon,
|
||||||
AlertCircleIcon,
|
AlertCircleIcon,
|
||||||
CalendarIcon,
|
|
||||||
KeyRoundIcon,
|
|
||||||
ServerIcon,
|
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
ChevronDownIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
|
UploadIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import {
|
||||||
const STATUS_CONFIG: Record<
|
Stepper,
|
||||||
CertStatus,
|
StepperContent,
|
||||||
{
|
StepperIndicator,
|
||||||
label: string
|
StepperItem,
|
||||||
icon: ReactNode
|
StepperNav,
|
||||||
badge: string
|
StepperPanel,
|
||||||
row: string
|
StepperSeparator,
|
||||||
}
|
StepperTitle,
|
||||||
> = {
|
StepperTrigger,
|
||||||
valid: {
|
} from "@/components/reui/stepper"
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
|
function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
|
||||||
return {
|
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[] }) {
|
function CertPartAlertExpired({ expired }: { expired: CertificateDto[] }) {
|
||||||
return (
|
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">
|
<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() {
|
function CertPartReference() {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -679,6 +355,7 @@ function CertPartIssueForm({
|
|||||||
setIssueTrustWww,
|
setIssueTrustWww,
|
||||||
issueTrustApi,
|
issueTrustApi,
|
||||||
setIssueTrustApi,
|
setIssueTrustApi,
|
||||||
|
step,
|
||||||
}: {
|
}: {
|
||||||
serverList: Server[]
|
serverList: Server[]
|
||||||
issueServerId: string
|
issueServerId: string
|
||||||
@@ -693,12 +370,15 @@ function CertPartIssueForm({
|
|||||||
setIssueTrustWww: (v: boolean) => void
|
setIssueTrustWww: (v: boolean) => void
|
||||||
issueTrustApi: boolean
|
issueTrustApi: boolean
|
||||||
setIssueTrustApi: (v: boolean) => void
|
setIssueTrustApi: (v: boolean) => void
|
||||||
|
step?: 1 | 2 | 3 | 4
|
||||||
}) {
|
}) {
|
||||||
|
const showAll = step == null
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
{(showAll || step === 1) && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Основные</SectionTitle>
|
<SectionTitle>Основные</SectionTitle>
|
||||||
<Field label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
|
<FormField label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
|
||||||
<select
|
<select
|
||||||
value={issueServerId}
|
value={issueServerId}
|
||||||
onChange={(e) => setIssueServerId(e.target.value)}
|
onChange={(e) => setIssueServerId(e.target.value)}
|
||||||
@@ -713,41 +393,45 @@ function CertPartIssueForm({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
|
<FormField label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
|
||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
value={issueCertName}
|
value={issueCertName}
|
||||||
onChange={(e) => setIssueCertName(e.target.value)}
|
onChange={(e) => setIssueCertName(e.target.value)}
|
||||||
placeholder="router-le"
|
placeholder="router-le"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(showAll || step === 2) && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Домены</SectionTitle>
|
<SectionTitle>Домены</SectionTitle>
|
||||||
<Field label="Common Name" required hint="Основное имя в сертификате">
|
<FormField label="Common Name" required hint="Основное имя в сертификате">
|
||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
value={issueCommonName}
|
value={issueCommonName}
|
||||||
onChange={(e) => setIssueCommonName(e.target.value)}
|
onChange={(e) => setIssueCommonName(e.target.value)}
|
||||||
placeholder="vpn.example.com"
|
placeholder="vpn.example.com"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="SAN" hint="По одному имени в строке">
|
<FormField label="SAN" hint="По одному имени в строке">
|
||||||
<textarea
|
<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"
|
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}
|
value={issueSans}
|
||||||
onChange={(e) => setIssueSans(e.target.value)}
|
onChange={(e) => setIssueSans(e.target.value)}
|
||||||
placeholder="www.example.com"
|
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">
|
<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's Encrypt · DNS-01 (Cloudflare)</p>
|
<p className="font-medium text-foreground mb-1">Let's Encrypt · DNS-01 (Cloudflare)</p>
|
||||||
<p>TXT-запись создаётся в Cloudflare, сертификат импортируется на выбранный RouterOS.</p>
|
<p>TXT-запись создаётся в Cloudflare, сертификат импортируется на выбранный RouterOS.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(showAll || step === 3) && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Импорт на RouterOS</SectionTitle>
|
<SectionTitle>Импорт на RouterOS</SectionTitle>
|
||||||
<div className="flex items-center justify-between">
|
<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-sm font-medium">Trust store · www</p>
|
||||||
<p className="text-xs text-muted-foreground">Веб-интерфейс и HTTPS-сервисы</p>
|
<p className="text-xs text-muted-foreground">Веб-интерфейс и HTTPS-сервисы</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={issueTrustWww} onChange={setIssueTrustWww} />
|
<FormToggle checked={issueTrustWww} onChange={setIssueTrustWww} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Trust store · api</p>
|
<p className="text-sm font-medium">Trust store · api</p>
|
||||||
<p className="text-xs text-muted-foreground">REST API и управление</p>
|
<p className="text-xs text-muted-foreground">REST API и управление</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={issueTrustApi} onChange={setIssueTrustApi} />
|
<FormToggle checked={issueTrustApi} onChange={setIssueTrustApi} />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -776,13 +473,14 @@ export default function CertificatesPage() {
|
|||||||
|
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [statusFilter, setStatusFilter] = useState<CertStatus | "all">("all")
|
const [statusFilter, setStatusFilter] = useState<CertStatus | "all">("all")
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
|
||||||
const [certificates, setCertificates] = useState<CertificateDto[]>([])
|
const [certificates, setCertificates] = useState<CertificateDto[]>([])
|
||||||
const [loadState, setLoadState] = useState<"idle" | "loading" | "error">("idle")
|
const [loadState, setLoadState] = useState<"idle" | "loading" | "error">("idle")
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
const [serverList, setServerList] = useState<Server[]>([])
|
const [serverList, setServerList] = useState<Server[]>([])
|
||||||
|
|
||||||
const [issueOpen, setIssueOpen] = useState(false)
|
const [issueOpen, setIssueOpen] = useState(false)
|
||||||
|
const [issueStep, setIssueStep] = useState(1)
|
||||||
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
const [issueBusy, setIssueBusy] = useState(false)
|
const [issueBusy, setIssueBusy] = useState(false)
|
||||||
const [issueServerId, setIssueServerId] = useState("")
|
const [issueServerId, setIssueServerId] = useState("")
|
||||||
const [issueCertName, setIssueCertName] = useState("")
|
const [issueCertName, setIssueCertName] = useState("")
|
||||||
@@ -888,15 +586,6 @@ export default function CertificatesPage() {
|
|||||||
})
|
})
|
||||||
}, [displayCerts, search, statusFilter])
|
}, [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() {
|
async function handleRefresh() {
|
||||||
if (!liveReady) return
|
if (!liveReady) return
|
||||||
try {
|
try {
|
||||||
@@ -1004,7 +693,11 @@ export default function CertificatesPage() {
|
|||||||
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
|
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</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" />
|
<PlusIcon className="size-4" />
|
||||||
Выпустить сертификат
|
Выпустить сертификат
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1053,7 +746,7 @@ export default function CertificatesPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card>
|
<DataPageCard>
|
||||||
<CertPartTableToolbar
|
<CertPartTableToolbar
|
||||||
search={search}
|
search={search}
|
||||||
setSearch={setSearch}
|
setSearch={setSearch}
|
||||||
@@ -1061,36 +754,18 @@ export default function CertificatesPage() {
|
|||||||
setStatusFilter={setStatusFilter}
|
setStatusFilter={setStatusFilter}
|
||||||
filteredCount={filtered.length}
|
filteredCount={filtered.length}
|
||||||
/>
|
/>
|
||||||
<div className="overflow-x-auto">
|
<CertificatesDataGrid
|
||||||
<div className="min-w-[48rem]">
|
certificates={filtered}
|
||||||
<CertPartTableHeader />
|
serverMap={serverById}
|
||||||
{prefsHydrated && isLive && loadState === "loading" && displayCerts.length === 0 ? (
|
isLoading={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)}
|
|
||||||
/>
|
/>
|
||||||
))
|
</DataPageCard>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<CertPartReference />
|
<CertPartReference />
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
<SheetTitle>Выпуск сертификата</SheetTitle>
|
<SheetTitle>Выпуск сертификата</SheetTitle>
|
||||||
@@ -1099,8 +774,28 @@ export default function CertificatesPage() {
|
|||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
<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
|
<CertPartIssueForm
|
||||||
|
step={s as 1 | 2 | 3 | 4}
|
||||||
serverList={serverList}
|
serverList={serverList}
|
||||||
issueServerId={issueServerId}
|
issueServerId={issueServerId}
|
||||||
setIssueServerId={setIssueServerId}
|
setIssueServerId={setIssueServerId}
|
||||||
@@ -1115,24 +810,51 @@ export default function CertificatesPage() {
|
|||||||
issueTrustApi={issueTrustApi}
|
issueTrustApi={issueTrustApi}
|
||||||
setIssueTrustApi={setIssueTrustApi}
|
setIssueTrustApi={setIssueTrustApi}
|
||||||
/>
|
/>
|
||||||
</div>
|
</StepperContent>
|
||||||
|
))}
|
||||||
|
</StepperPanel>
|
||||||
|
</Stepper>
|
||||||
|
|
||||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
<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 render={<Button variant="outline" className="flex-1" disabled={issueBusy} />}>
|
||||||
Отмена
|
Отмена
|
||||||
</SheetClose>
|
</SheetClose>
|
||||||
|
{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
|
<Button
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!liveReady || issueBusy}
|
disabled={!liveReady || issueBusy}
|
||||||
onClick={() => {
|
onClick={() => { void handleIssue() }}
|
||||||
void handleIssue()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{issueBusy ? "Выпуск…" : "Выпустить"}
|
{issueBusy ? "Выпуск…" : "Выпустить"}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-117
@@ -2,14 +2,22 @@
|
|||||||
|
|
||||||
import { useState, useMemo, useEffect } from "react"
|
import { useState, useMemo, useEffect } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
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 {
|
import {
|
||||||
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
||||||
} from "@/components/ui/card"
|
} from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
PlusIcon, SearchIcon, TagIcon, ServerIcon, FilterIcon,
|
PlusIcon, SearchIcon, TagIcon, FilterIcon,
|
||||||
ChevronRightIcon, CopyIcon, CheckIcon, TrashIcon, PencilIcon,
|
CopyIcon, CheckIcon, TrashIcon, PencilIcon,
|
||||||
LoaderCircleIcon,
|
LoaderCircleIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -19,21 +27,8 @@ import { useEvoBGP } from "@/lib/evobgp-context"
|
|||||||
|
|
||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type CommType = "standard" | "no-export" | "no-advertise" | "local-as" | "custom"
|
type Community = CommunityRow
|
||||||
|
type CommType = CommunityRow["type"]
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── mock data ────────────────────────────────────────────────────────────────
|
// ─── 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 ─────────────────────────────────────────────────────────────────────
|
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function CommunitiesPage() {
|
export default function CommunitiesPage() {
|
||||||
@@ -225,84 +198,15 @@ export default function CommunitiesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* list */}
|
{/* list */}
|
||||||
<Card className="overflow-hidden">
|
<DataPageCard>
|
||||||
<div className="overflow-x-auto">
|
<CommunitiesDataGrid
|
||||||
<table className="w-full text-sm">
|
communities={filtered}
|
||||||
<thead>
|
selectedId={selected?.id}
|
||||||
<tr className="border-b text-xs text-muted-foreground">
|
copiedValue={copied}
|
||||||
<th className="text-left font-medium px-4 py-2.5">Community</th>
|
onSelect={setSelected}
|
||||||
<th className="text-left font-medium px-4 py-2.5">Имя / описание</th>
|
onCopy={handleCopy}
|
||||||
<th className="text-left font-medium px-4 py-2.5">Тип</th>
|
/>
|
||||||
<th className="text-left font-medium px-4 py-2.5">Действие</th>
|
</DataPageCard>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── detail panel ── */}
|
{/* ── detail panel ── */}
|
||||||
|
|||||||
+14
-128
@@ -22,8 +22,10 @@ import type { PingProbe, Server, ServerStatus, ServerType } from "@/lib/data"
|
|||||||
import type { GreTunnel } from "@/lib/data"
|
import type { GreTunnel } from "@/lib/data"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { Flag } from "@/components/flag"
|
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 { 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 { cn } from "@/lib/utils"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency"
|
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 {
|
function fmtIntRu(n: number): string {
|
||||||
return n.toLocaleString("ru-RU")
|
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 {
|
interface BackendServerRow {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
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() {
|
export default function DashboardPage() {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
@@ -1020,67 +955,18 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0 px-0">
|
<CardContent className="pt-0 px-0">
|
||||||
<div className="overflow-x-auto">
|
<DataPageCard className="rounded-none border-0 shadow-none">
|
||||||
<table className="w-full text-sm">
|
<DashboardActiveProbesDataGrid
|
||||||
<thead>
|
probes={activeProbesTable}
|
||||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
catalog={probeServerCatalog}
|
||||||
<th className="text-left font-medium px-5 py-2.5 w-[min(280px,32vw)]">Источник</th>
|
isLoading={isLive && probesLoading && liveProbes === null}
|
||||||
<th className="text-left font-medium px-4 py-2.5">Проба</th>
|
emptyDescription={
|
||||||
<th className="text-left font-medium px-4 py-2.5">Цель</th>
|
isLive && probesError
|
||||||
<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 и настройки проб на странице мониторинга."
|
? "Нет данных о пробах. Проверьте сборщик uptime и настройки проб на странице мониторинга."
|
||||||
: "Нет проб с звездой на дашборде. Включите пробу и отметьте ★ в разделе «Мониторинг»."}
|
: "Нет проб с звездой на дашборде. Включите пробу и отметьте ★ в разделе «Мониторинг»."
|
||||||
</td>
|
}
|
||||||
</tr>
|
/>
|
||||||
)}
|
</DataPageCard>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
+157
-492
@@ -3,6 +3,7 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
@@ -27,7 +28,6 @@ import {
|
|||||||
import { requestJson, ApiClientError } from "@/shared/api/http-client"
|
import { requestJson, ApiClientError } from "@/shared/api/http-client"
|
||||||
import {
|
import {
|
||||||
parseSchedulerRunSnapshot,
|
parseSchedulerRunSnapshot,
|
||||||
type AlertEngineRuleDiagSnapshot,
|
|
||||||
type AlertEngineRunSnapshot,
|
type AlertEngineRunSnapshot,
|
||||||
type GreBgpSnapshotRunSnapshot,
|
type GreBgpSnapshotRunSnapshot,
|
||||||
type InternetPathRunSnapshot,
|
type InternetPathRunSnapshot,
|
||||||
@@ -40,6 +40,19 @@ import {
|
|||||||
type SpeedScheduledRunSnapshot,
|
type SpeedScheduledRunSnapshot,
|
||||||
type TrafficRunSnapshot,
|
type TrafficRunSnapshot,
|
||||||
} from "@/lib/scheduler-run-snapshot"
|
} from "@/lib/scheduler-run-snapshot"
|
||||||
|
import {
|
||||||
|
AlertEngineRuleDiagGrid,
|
||||||
|
PingSnapshotGrid,
|
||||||
|
ResourcesSnapshotGrid,
|
||||||
|
ServersRestPingSnapshotGrid,
|
||||||
|
SpeedSnapshotGrid,
|
||||||
|
TrafficSnapshotGrid,
|
||||||
|
} from "@/components/data-grids/snapshot-data-grid"
|
||||||
|
import {
|
||||||
|
DataCollectionSchedulerDataGrid,
|
||||||
|
type SchedulerJobGridRow,
|
||||||
|
} from "@/components/data-grids/data-collection-scheduler-data-grid"
|
||||||
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
AlertCircleIcon,
|
AlertCircleIcon,
|
||||||
@@ -79,54 +92,12 @@ function collectSettledErrors(results: PromiseSettledResult<unknown>[], labels:
|
|||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toggle({
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
checked: boolean
|
|
||||||
onChange: (v: boolean) => void
|
|
||||||
disabled?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={checked}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
|
|
||||||
disabled ? "cursor-not-allowed opacity-50" : "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 fmtMs(ms: number): string {
|
function fmtMs(ms: number): string {
|
||||||
if (ms < 1000) return `${ms} мс`
|
if (ms < 1000) return `${ms} мс`
|
||||||
const s = ms / 1000
|
const s = ms / 1000
|
||||||
return s < 60 ? `${s.toFixed(1)} с` : `${Math.floor(s / 60)} м ${Math.round(s % 60)} с`
|
return s < 60 ? `${s.toFixed(1)} с` : `${Math.floor(s / 60)} м ${Math.round(s % 60)} с`
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtUptimeSec(sec: number): string {
|
|
||||||
if (sec <= 0) return "—"
|
|
||||||
const d = Math.floor(sec / 86400)
|
|
||||||
const h = Math.floor((sec % 86400) / 3600)
|
|
||||||
const m = Math.floor((sec % 3600) / 60)
|
|
||||||
if (d > 0) return `${d}д ${h}ч`
|
|
||||||
if (h > 0) return `${h}ч ${m}м`
|
|
||||||
return `${m}м`
|
|
||||||
}
|
|
||||||
|
|
||||||
function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||||
if (snap.job === "traffic") {
|
if (snap.job === "traffic") {
|
||||||
const t = snap as TrafficRunSnapshot
|
const t = snap as TrafficRunSnapshot
|
||||||
@@ -144,44 +115,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Сэмплы на момент <span className="font-mono tabular-nums">{new Date(t.sampledAt).toLocaleString("ru-RU")}</span>
|
Сэмплы на момент <span className="font-mono tabular-nums">{new Date(t.sampledAt).toLocaleString("ru-RU")}</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="overflow-x-auto rounded-md border border-border">
|
<DataPageCard>
|
||||||
<table className="w-full text-xs">
|
<TrafficSnapshotGrid servers={t.servers} />
|
||||||
<thead>
|
</DataPageCard>
|
||||||
<tr className="border-b border-border bg-muted/40 text-muted-foreground text-left">
|
|
||||||
<th className="px-3 py-2 font-medium">Сервер</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Хост</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Результат</th>
|
|
||||||
<th className="px-3 py-2 font-medium text-right">IF</th>
|
|
||||||
<th className="px-3 py-2 font-medium text-right">Σ RX</th>
|
|
||||||
<th className="px-3 py-2 font-medium text-right">Σ TX</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Ошибка</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{t.servers.map((s) => (
|
|
||||||
<tr key={s.serverId} className="hover:bg-muted/30">
|
|
||||||
<td className="px-3 py-2 font-medium">{s.name}</td>
|
|
||||||
<td className="px-3 py-2 font-mono text-muted-foreground">{s.host}</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
{s.ok ? (
|
|
||||||
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400">
|
|
||||||
ok
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive">
|
|
||||||
ошибка
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums">{s.interfaces ?? "—"}</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums">{s.sumRxMbps != null ? `${s.sumRxMbps} Мбит/с` : "—"}</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums">{s.sumTxMbps != null ? `${s.sumTxMbps} Мбит/с` : "—"}</td>
|
|
||||||
<td className="px-3 py-2 text-destructive max-w-[220px] truncate" title={s.error}>{s.error ?? "—"}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -201,59 +137,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Сэмплы на <span className="font-mono tabular-nums">{new Date(u.sampledAt).toLocaleString("ru-RU")}</span>
|
Сэмплы на <span className="font-mono tabular-nums">{new Date(u.sampledAt).toLocaleString("ru-RU")}</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="overflow-x-auto rounded-md border border-border">
|
<DataPageCard>
|
||||||
<table className="w-full text-xs">
|
<ResourcesSnapshotGrid servers={u.servers} />
|
||||||
<thead>
|
</DataPageCard>
|
||||||
<tr className="border-b border-border bg-muted/40 text-muted-foreground text-left">
|
|
||||||
<th className="px-3 py-2 font-medium">Сервер</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Статус</th>
|
|
||||||
<th className="px-3 py-2 font-medium text-right">CPU %</th>
|
|
||||||
<th className="px-3 py-2 font-medium text-right">Память</th>
|
|
||||||
<th className="px-3 py-2 font-medium text-right">% RAM</th>
|
|
||||||
<th className="px-3 py-2 font-medium text-right">Диск своб.</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Uptime</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Плата / ROS</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Ошибка</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{u.servers.map((s) => (
|
|
||||||
<tr key={s.serverId} className="hover:bg-muted/30">
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<span className="font-medium">{s.name}</span>
|
|
||||||
<span className="block font-mono text-[10px] text-muted-foreground">{s.host}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"text-[10px]",
|
|
||||||
s.status === "online" && "border-emerald-500/40 text-emerald-700 dark:text-emerald-400",
|
|
||||||
s.status === "offline" && "border-destructive/50 text-destructive",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{s.status}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums">{s.cpuLoadPct ?? "—"}</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums whitespace-nowrap">
|
|
||||||
{s.memUsedMb != null && s.memTotalMb != null ? `${s.memUsedMb} / ${s.memTotalMb} МБ` : "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums">{s.memUsedPct != null ? `${s.memUsedPct}%` : "—"}</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums whitespace-nowrap">
|
|
||||||
{s.diskFreeMb != null && s.diskTotalMb != null ? `${s.diskFreeMb} / ${s.diskTotalMb} МБ` : "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 tabular-nums">{s.uptimeSeconds != null ? fmtUptimeSec(s.uptimeSeconds) : "—"}</td>
|
|
||||||
<td className="px-3 py-2 max-w-[140px]">
|
|
||||||
<span className="block truncate" title={s.boardName}>{s.boardName || "—"}</span>
|
|
||||||
<span className="block truncate text-muted-foreground font-mono text-[10px]" title={s.rosVersion}>{s.rosVersion || ""}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-destructive max-w-[160px] truncate" title={s.error}>{s.error ?? "—"}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -274,42 +160,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
GET <span className="font-mono">/system/identity</span> на{" "}
|
GET <span className="font-mono">/system/identity</span> на{" "}
|
||||||
<span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span>
|
<span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="overflow-x-auto rounded-md border border-border">
|
<DataPageCard>
|
||||||
<table className="w-full text-xs">
|
<ServersRestPingSnapshotGrid servers={s.servers} />
|
||||||
<thead>
|
</DataPageCard>
|
||||||
<tr className="border-b border-border bg-muted/40 text-muted-foreground text-left">
|
|
||||||
<th className="px-3 py-2 font-medium">Сервер</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Хост</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Результат</th>
|
|
||||||
<th className="px-3 py-2 font-medium text-right">RTT REST</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Ошибка</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{s.servers.map((row) => (
|
|
||||||
<tr key={row.serverId} className="hover:bg-muted/30">
|
|
||||||
<td className="px-3 py-2 font-medium">{row.name}</td>
|
|
||||||
<td className="px-3 py-2 font-mono text-muted-foreground">{row.host}</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
{row.ok ? (
|
|
||||||
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400">
|
|
||||||
ok
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive">
|
|
||||||
недоступен
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums">
|
|
||||||
{row.latencyMs != null ? `${row.latencyMs} мс` : "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-destructive max-w-[220px] truncate" title={row.error}>{row.error ?? "—"}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -332,41 +185,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Сэмплы на <span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span> — только пробы, для которых записан замер в этом тике
|
Сэмплы на <span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span> — только пробы, для которых записан замер в этом тике
|
||||||
</p>
|
</p>
|
||||||
<div className="overflow-x-auto rounded-md border border-border">
|
<DataPageCard>
|
||||||
<table className="w-full text-xs">
|
<PingSnapshotGrid probes={p.probes} />
|
||||||
<thead>
|
</DataPageCard>
|
||||||
<tr className="border-b border-border bg-muted/40 text-muted-foreground text-left">
|
|
||||||
<th className="px-3 py-2 font-medium">Проба</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Цель</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Источник</th>
|
|
||||||
<th className="px-3 py-2 font-medium">IF</th>
|
|
||||||
<th className="px-3 py-2 font-medium text-right">RTT</th>
|
|
||||||
<th className="px-3 py-2 font-medium text-right">Loss</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Статус</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Ошибка</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{p.probes.map((x) => (
|
|
||||||
<tr key={`${x.probeId}-${x.target}`} className="hover:bg-muted/30">
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<span className="font-medium">{x.name}</span>
|
|
||||||
<span className="block font-mono text-[10px] text-muted-foreground">{x.probeId}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 font-mono">{x.target}</td>
|
|
||||||
<td className="px-3 py-2">{x.srcServerName}</td>
|
|
||||||
<td className="px-3 py-2 font-mono text-muted-foreground">{x.srcInterface || "—"}</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums">{x.rttMs != null ? `${x.rttMs} мс` : "—"}</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums">{x.lossPct}%</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<Badge variant="outline" className="text-[10px]">{x.status}</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-destructive max-w-[180px] truncate" title={x.error}>{x.error ?? "—"}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -377,56 +198,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> — по очереди для каждой включённой пробы
|
Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> — по очереди для каждой включённой пробы
|
||||||
</p>
|
</p>
|
||||||
<div className="overflow-x-auto rounded-md border border-border">
|
<DataPageCard>
|
||||||
<table className="w-full text-xs">
|
<SpeedSnapshotGrid runs={s.runs} />
|
||||||
<thead>
|
</DataPageCard>
|
||||||
<tr className="border-b border-border bg-muted/40 text-muted-foreground text-left">
|
|
||||||
<th className="px-3 py-2 font-medium">Проба</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Маршрут</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Интерфейсы</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Протокол</th>
|
|
||||||
<th className="px-3 py-2 font-medium text-right">TX</th>
|
|
||||||
<th className="px-3 py-2 font-medium text-right">RX</th>
|
|
||||||
<th className="px-3 py-2 font-medium text-right">Ping RTT</th>
|
|
||||||
<th className="px-3 py-2 font-medium text-right">Loss</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Результат</th>
|
|
||||||
<th className="px-3 py-2 font-medium">Ошибка</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{s.runs.map((x) => (
|
|
||||||
<tr key={x.probeId} className="hover:bg-muted/30">
|
|
||||||
<td className="px-3 py-2 font-mono">{x.probeId}</td>
|
|
||||||
<td className="px-3 py-2 whitespace-nowrap">
|
|
||||||
{x.srcServerName} <span className="text-muted-foreground">→</span> {x.dstServerName}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 font-mono text-[10px]">
|
|
||||||
<span className="block">{x.srcInterface || "—"}</span>
|
|
||||||
<span className="block text-muted-foreground">{x.dstInterface || "—"}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">{x.protocol} / {x.direction} / {x.durationSec}s</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums">{x.txAvgMbps != null ? `${Number(x.txAvgMbps).toFixed(1)}` : "—"}</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums">{x.rxAvgMbps != null ? `${Number(x.rxAvgMbps).toFixed(1)}` : "—"}</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums">{x.pingRttMs != null ? `${x.pingRttMs} мс` : "—"}</td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums">{x.pingLossPct != null ? `${x.pingLossPct}%` : "—"}</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
{x.ok ? (
|
|
||||||
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400">ok</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive">ошибка</Badge>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 max-w-[200px]">
|
|
||||||
<span className="text-destructive block truncate" title={x.error}>{x.error ?? ""}</span>
|
|
||||||
{x.pingError ? (
|
|
||||||
<span className="text-[10px] text-amber-600 dark:text-amber-400 block truncate" title={x.pingError ?? ""}>ping: {x.pingError}</span>
|
|
||||||
) : null}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -566,36 +340,6 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
}
|
}
|
||||||
if (snap.job === "alert_engine") {
|
if (snap.job === "alert_engine") {
|
||||||
const a = snap as AlertEngineRunSnapshot
|
const a = snap as AlertEngineRunSnapshot
|
||||||
const transitionRu = (t: AlertEngineRuleDiagSnapshot["hitTransition"]) => {
|
|
||||||
switch (t) {
|
|
||||||
case "problem":
|
|
||||||
return "проблема"
|
|
||||||
case "recovery":
|
|
||||||
return "восстановление"
|
|
||||||
case "neutral":
|
|
||||||
return "нейтрально"
|
|
||||||
default:
|
|
||||||
return "—"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const blockedRu = (b: AlertEngineRuleDiagSnapshot["blocked"]) => {
|
|
||||||
switch (b) {
|
|
||||||
case "no_hit":
|
|
||||||
return "условие не выполнено"
|
|
||||||
case "stability":
|
|
||||||
return "стабильность (confirmStabilitySec)"
|
|
||||||
case "cooldown":
|
|
||||||
return "cooldown"
|
|
||||||
case "no_telegram":
|
|
||||||
return "нет Telegram"
|
|
||||||
case "dedupe_positive":
|
|
||||||
return "дедуп восстановления"
|
|
||||||
case "in_group":
|
|
||||||
return "в группе (отдельно не шлём)"
|
|
||||||
default:
|
|
||||||
return "—"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -636,40 +380,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
{a.ruleDiag && a.ruleDiag.length > 0 ? (
|
{a.ruleDiag && a.ruleDiag.length > 0 ? (
|
||||||
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 space-y-2">
|
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 space-y-2">
|
||||||
<p className="text-[11px] font-medium text-muted-foreground">По правилам (почему не ушло в Telegram)</p>
|
<p className="text-[11px] font-medium text-muted-foreground">По правилам (почему не ушло в Telegram)</p>
|
||||||
<div className="overflow-x-auto">
|
<DataPageCard className="border-0 shadow-none bg-transparent">
|
||||||
<table className="w-full text-[11px] border-collapse">
|
<AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} />
|
||||||
<thead>
|
</DataPageCard>
|
||||||
<tr className="text-left text-muted-foreground border-b border-border">
|
|
||||||
<th className="py-1 pr-2 font-medium">ID правила</th>
|
|
||||||
<th className="py-1 pr-2 font-medium">Сработало</th>
|
|
||||||
<th className="py-1 pr-2 font-medium">Тип срабатывания</th>
|
|
||||||
<th className="py-1 pr-2 font-medium">Стабильность</th>
|
|
||||||
<th className="py-1 pr-2 font-medium">Кулдаун</th>
|
|
||||||
<th className="py-1 pr-2 font-medium">Telegram</th>
|
|
||||||
<th className="py-1 pr-2 font-medium">Сообщение</th>
|
|
||||||
<th className="py-1 font-medium">Причина блока</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{a.ruleDiag.map((d) => (
|
|
||||||
<tr key={d.ruleId} className="border-b border-border/60 font-mono">
|
|
||||||
<td className="py-1 pr-2 max-w-[140px] truncate" title={d.ruleId}>
|
|
||||||
{d.ruleId}
|
|
||||||
</td>
|
|
||||||
<td className="py-1 pr-2">{d.evalHit ? "Да" : "Нет"}</td>
|
|
||||||
<td className="py-1 pr-2">{transitionRu(d.hitTransition)}</td>
|
|
||||||
<td className="py-1 pr-2">{d.stabilityOk ? "Да" : "Нет"}</td>
|
|
||||||
<td className="py-1 pr-2">{d.cooldownOk ? "Да" : "Нет"}</td>
|
|
||||||
<td className="py-1 pr-2">{d.telegramOk ? "Да" : "Нет"}</td>
|
|
||||||
<td className="py-1 pr-2 max-w-[280px] truncate text-muted-foreground" title={d.hitMessage ?? ""}>
|
|
||||||
{d.hitMessage ?? "—"}
|
|
||||||
</td>
|
|
||||||
<td className="py-1 text-muted-foreground">{blockedRu(d.blocked)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -1088,6 +801,130 @@ export default function DataCollectionPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const schedulerGridRows = useMemo<SchedulerJobGridRow[]>(() => {
|
||||||
|
return SCHEDULER_JOB_KEYS.map((jobKey) => {
|
||||||
|
const j = schedulerJobsByKey[jobKey]
|
||||||
|
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups"
|
||||||
|
const enabled = fixedSchedule
|
||||||
|
? Boolean(j?.enabled ?? true)
|
||||||
|
: jobKey === "traffic"
|
||||||
|
? draftTrafficEnabled
|
||||||
|
: jobKey === "servers_rest_ping"
|
||||||
|
? draftServersApiEnabled
|
||||||
|
: jobKey === "uptime_resources"
|
||||||
|
? draftResourcesEnabled
|
||||||
|
: jobKey === "uptime_ping"
|
||||||
|
? draftPingEnabled
|
||||||
|
: jobKey === "uptime_speed"
|
||||||
|
? draftSpeedEnabled
|
||||||
|
: jobKey === "certificates_renew"
|
||||||
|
? draftCertRenewEnabled
|
||||||
|
: draftInternetPathEnabled
|
||||||
|
const intervalValue = fixedSchedule
|
||||||
|
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
||||||
|
: jobKey === "traffic"
|
||||||
|
? trafficIntervalDraft
|
||||||
|
: jobKey === "servers_rest_ping"
|
||||||
|
? serversApiIntervalDraft
|
||||||
|
: jobKey === "uptime_resources"
|
||||||
|
? uptimeResourceIntervalDraft
|
||||||
|
: jobKey === "uptime_ping"
|
||||||
|
? uptimeIntervalDraft
|
||||||
|
: jobKey === "uptime_speed"
|
||||||
|
? uptimeSpeedIntervalDraft
|
||||||
|
: jobKey === "certificates_renew"
|
||||||
|
? certRenewIntervalDraft
|
||||||
|
: internetPathIntervalDraft
|
||||||
|
const onIntervalChange = fixedSchedule
|
||||||
|
? () => {}
|
||||||
|
: jobKey === "traffic"
|
||||||
|
? setTrafficIntervalDraft
|
||||||
|
: jobKey === "servers_rest_ping"
|
||||||
|
? setServersApiIntervalDraft
|
||||||
|
: jobKey === "uptime_resources"
|
||||||
|
? setUptimeResourceIntervalDraft
|
||||||
|
: jobKey === "uptime_ping"
|
||||||
|
? setUptimeIntervalDraft
|
||||||
|
: jobKey === "uptime_speed"
|
||||||
|
? setUptimeSpeedIntervalDraft
|
||||||
|
: jobKey === "certificates_renew"
|
||||||
|
? setCertRenewIntervalDraft
|
||||||
|
: setInternetPathIntervalDraft
|
||||||
|
const defaultInterval = fixedSchedule
|
||||||
|
? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
||||||
|
: jobKey === "traffic"
|
||||||
|
? 30
|
||||||
|
: jobKey === "servers_rest_ping"
|
||||||
|
? 120
|
||||||
|
: jobKey === "uptime_resources"
|
||||||
|
? 300
|
||||||
|
: jobKey === "uptime_ping"
|
||||||
|
? 15
|
||||||
|
: jobKey === "uptime_speed"
|
||||||
|
? 60
|
||||||
|
: jobKey === "certificates_renew"
|
||||||
|
? 21600
|
||||||
|
: 300
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: jobKey,
|
||||||
|
jobKey,
|
||||||
|
label: SCHEDULER_JOB_LABELS[jobKey] ?? jobKey,
|
||||||
|
description: SCHEDULER_JOB_DESCRIPTIONS[jobKey],
|
||||||
|
fixedSchedule,
|
||||||
|
enabled,
|
||||||
|
intervalValue,
|
||||||
|
intervalReadOnly: fixedSchedule,
|
||||||
|
intervalDisabled: !enabled && !fixedSchedule,
|
||||||
|
defaultInterval,
|
||||||
|
job: j,
|
||||||
|
onEnabledChange: fixedSchedule
|
||||||
|
? undefined
|
||||||
|
: (nextEnabled) => {
|
||||||
|
void handleJobEnabledChange(jobKey, nextEnabled)
|
||||||
|
},
|
||||||
|
onIntervalChange,
|
||||||
|
onRunNow: async () => {
|
||||||
|
setRunNowJobKey(jobKey)
|
||||||
|
setCollectorError(null)
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/scheduler/jobs/${encodeURIComponent(jobKey)}/run-now`, {
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
await loadCollectors()
|
||||||
|
} catch (e) {
|
||||||
|
setCollectorError(e instanceof Error ? e.message : "Ошибка запуска")
|
||||||
|
} finally {
|
||||||
|
setRunNowJobKey(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
runNowLoading: runNowJobKey === jobKey,
|
||||||
|
saveBusy: schedulerSaveBusy,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [
|
||||||
|
apiFetch,
|
||||||
|
certRenewIntervalDraft,
|
||||||
|
draftCertRenewEnabled,
|
||||||
|
draftInternetPathEnabled,
|
||||||
|
draftPingEnabled,
|
||||||
|
draftResourcesEnabled,
|
||||||
|
draftServersApiEnabled,
|
||||||
|
draftSpeedEnabled,
|
||||||
|
draftTrafficEnabled,
|
||||||
|
handleJobEnabledChange,
|
||||||
|
internetPathIntervalDraft,
|
||||||
|
loadCollectors,
|
||||||
|
runNowJobKey,
|
||||||
|
schedulerJobsByKey,
|
||||||
|
schedulerSaveBusy,
|
||||||
|
serversApiIntervalDraft,
|
||||||
|
trafficIntervalDraft,
|
||||||
|
uptimeIntervalDraft,
|
||||||
|
uptimeResourceIntervalDraft,
|
||||||
|
uptimeSpeedIntervalDraft,
|
||||||
|
])
|
||||||
|
|
||||||
const enabledJobsCount = useMemo(() => {
|
const enabledJobsCount = useMemo(() => {
|
||||||
const jobs = uptimeCollector?.scheduler?.jobs
|
const jobs = uptimeCollector?.scheduler?.jobs
|
||||||
if (jobs?.length) return jobs.filter((job) => job.enabled).length
|
if (jobs?.length) return jobs.filter((job) => job.enabled).length
|
||||||
@@ -1254,179 +1091,7 @@ export default function DataCollectionPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-0 pb-0">
|
<CardContent className="px-0 pb-0">
|
||||||
<div className="overflow-x-auto">
|
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
|
||||||
<th className="text-left font-medium px-5 py-3">Задача</th>
|
|
||||||
<th className="text-center font-medium px-3 py-3 w-[1%]">Вкл</th>
|
|
||||||
<th className="text-left font-medium px-4 py-3">Интервал (с)</th>
|
|
||||||
<th className="text-left font-medium px-4 py-3">Последний прогон</th>
|
|
||||||
<th className="text-left font-medium px-4 py-3">Статус</th>
|
|
||||||
<th className="text-right font-medium px-4 py-3 w-[1%]">Сейчас</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{SCHEDULER_JOB_KEYS.map((jobKey) => {
|
|
||||||
const j = schedulerJobsByKey[jobKey]
|
|
||||||
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups"
|
|
||||||
const en = fixedSchedule
|
|
||||||
? Boolean(j?.enabled ?? true)
|
|
||||||
: jobKey === "traffic"
|
|
||||||
? draftTrafficEnabled
|
|
||||||
: jobKey === "servers_rest_ping"
|
|
||||||
? draftServersApiEnabled
|
|
||||||
: jobKey === "uptime_resources"
|
|
||||||
? draftResourcesEnabled
|
|
||||||
: jobKey === "uptime_ping"
|
|
||||||
? draftPingEnabled
|
|
||||||
: jobKey === "uptime_speed"
|
|
||||||
? draftSpeedEnabled
|
|
||||||
: jobKey === "certificates_renew"
|
|
||||||
? draftCertRenewEnabled
|
|
||||||
: draftInternetPathEnabled
|
|
||||||
const iv = fixedSchedule
|
|
||||||
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
|
||||||
: jobKey === "traffic"
|
|
||||||
? trafficIntervalDraft
|
|
||||||
: jobKey === "servers_rest_ping"
|
|
||||||
? serversApiIntervalDraft
|
|
||||||
: jobKey === "uptime_resources"
|
|
||||||
? uptimeResourceIntervalDraft
|
|
||||||
: jobKey === "uptime_ping"
|
|
||||||
? uptimeIntervalDraft
|
|
||||||
: jobKey === "uptime_speed"
|
|
||||||
? uptimeSpeedIntervalDraft
|
|
||||||
: jobKey === "certificates_renew"
|
|
||||||
? certRenewIntervalDraft
|
|
||||||
: internetPathIntervalDraft
|
|
||||||
const setIv = fixedSchedule
|
|
||||||
? () => {}
|
|
||||||
: jobKey === "traffic"
|
|
||||||
? setTrafficIntervalDraft
|
|
||||||
: jobKey === "servers_rest_ping"
|
|
||||||
? setServersApiIntervalDraft
|
|
||||||
: jobKey === "uptime_resources"
|
|
||||||
? setUptimeResourceIntervalDraft
|
|
||||||
: jobKey === "uptime_ping"
|
|
||||||
? setUptimeIntervalDraft
|
|
||||||
: jobKey === "uptime_speed"
|
|
||||||
? setUptimeSpeedIntervalDraft
|
|
||||||
: jobKey === "certificates_renew"
|
|
||||||
? setCertRenewIntervalDraft
|
|
||||||
: setInternetPathIntervalDraft
|
|
||||||
const defSec = fixedSchedule
|
|
||||||
? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
|
||||||
: jobKey === "traffic"
|
|
||||||
? 30
|
|
||||||
: jobKey === "servers_rest_ping"
|
|
||||||
? 120
|
|
||||||
: jobKey === "uptime_resources"
|
|
||||||
? 300
|
|
||||||
: jobKey === "uptime_ping"
|
|
||||||
? 15
|
|
||||||
: jobKey === "uptime_speed"
|
|
||||||
? 60
|
|
||||||
: jobKey === "certificates_renew"
|
|
||||||
? 21600
|
|
||||||
: 300
|
|
||||||
return (
|
|
||||||
<tr key={jobKey} className="hover:bg-muted/40">
|
|
||||||
<td className="px-5 py-3 align-top">
|
|
||||||
<span className="font-medium">{SCHEDULER_JOB_LABELS[jobKey] ?? jobKey}</span>
|
|
||||||
<p className="text-[11px] text-muted-foreground mt-0.5 leading-snug">
|
|
||||||
{SCHEDULER_JOB_DESCRIPTIONS[jobKey]}
|
|
||||||
</p>
|
|
||||||
<p className="text-[11px] text-muted-foreground font-mono mt-1">{jobKey}</p>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-3 text-center align-top">
|
|
||||||
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
|
|
||||||
<Toggle
|
|
||||||
checked={en}
|
|
||||||
disabled={fixedSchedule || schedulerSaveBusy}
|
|
||||||
onChange={(v) => {
|
|
||||||
if (fixedSchedule || schedulerSaveBusy) return
|
|
||||||
void handleJobEnabledChange(jobKey, v)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 w-28 align-top">
|
|
||||||
<Input
|
|
||||||
value={iv}
|
|
||||||
onChange={(e) => setIv(e.target.value)}
|
|
||||||
className="h-8 text-sm tabular-nums"
|
|
||||||
inputMode="numeric"
|
|
||||||
readOnly={fixedSchedule}
|
|
||||||
disabled={!en && !fixedSchedule}
|
|
||||||
placeholder={String(defSec)}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-xs text-muted-foreground align-top">
|
|
||||||
{j?.lastFinishedAt ? new Date(j.lastFinishedAt).toLocaleString("ru-RU") : "—"}
|
|
||||||
{j?.lastDurationMs != null && (
|
|
||||||
<span className="block text-[11px]">{j.lastDurationMs} мс</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 align-top">
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
{j?.running ? (
|
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
|
||||||
выполняется
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
{j?.lastStatus ? (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"text-[10px]",
|
|
||||||
j.lastStatus === "ok" && "border-emerald-500/40 text-emerald-700 dark:text-emerald-400",
|
|
||||||
j.lastStatus === "error" && "border-destructive/50 text-destructive",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{j.lastStatus}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
{j?.lastError ? (
|
|
||||||
<span
|
|
||||||
className="text-[10px] text-destructive max-w-[200px] truncate block"
|
|
||||||
title={j.lastError}
|
|
||||||
>
|
|
||||||
{j.lastError}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right align-top">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="h-8"
|
|
||||||
disabled={j?.running || runNowJobKey !== null}
|
|
||||||
onClick={async () => {
|
|
||||||
setRunNowJobKey(jobKey)
|
|
||||||
setCollectorError(null)
|
|
||||||
try {
|
|
||||||
await apiFetch(`/api/scheduler/jobs/${encodeURIComponent(jobKey)}/run-now`, {
|
|
||||||
method: "POST",
|
|
||||||
})
|
|
||||||
await loadCollectors()
|
|
||||||
} catch (e) {
|
|
||||||
setCollectorError(e instanceof Error ? e.message : "Ошибка запуска")
|
|
||||||
} finally {
|
|
||||||
setRunNowJobKey(null)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RefreshCwIcon className={cn("size-3.5", runNowJobKey === jobKey && "animate-spin")} />
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-3 px-5 py-4">
|
<div className="space-y-3 px-5 py-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
|||||||
+44
-54
@@ -1,18 +1,24 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
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 { domains as mockDomains } from "@/lib/data"
|
||||||
import { Button } from "@/components/ui/button"
|
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 { useDataSource } from "@/lib/data-source"
|
||||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function DomainsPage() {
|
export default function DomainsPage() {
|
||||||
const { mode } = useDataSource()
|
const { mode } = useDataSource()
|
||||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||||
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
const useEvoCatalog = mode === "live" && enabled
|
const useEvoCatalog = mode === "live" && enabled
|
||||||
|
|
||||||
@@ -22,13 +28,26 @@ export default function DomainsPage() {
|
|||||||
return snapshot?.domains ?? []
|
return snapshot?.domains ?? []
|
||||||
}, [useEvoCatalog, loading, snapshot])
|
}, [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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
|
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
|
||||||
actions={
|
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 variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||||
<Button size="sm"><PlusIcon className="size-4" />Добавить домен</Button>
|
<Button size="sm"><PlusIcon className="size-4" />Добавить домен</Button>
|
||||||
</>
|
</>
|
||||||
@@ -55,60 +74,31 @@ export default function DomainsPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataPageCard>
|
||||||
data={rows}
|
<DataPageToolbar
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder="Поиск по домену…"
|
searchPlaceholder="Поиск по домену…"
|
||||||
searchKeys={["domain", "asn", "filter"]}
|
countLabel={`${filtered.length} доменов`}
|
||||||
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>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
<DomainsDataGrid
|
||||||
|
domains={filtered}
|
||||||
|
isLoading={useEvoCatalog && loading && !snapshot}
|
||||||
|
pagination={useEvoCatalog}
|
||||||
|
/>
|
||||||
|
</DataPageCard>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-310
@@ -2,8 +2,14 @@
|
|||||||
|
|
||||||
import { useMemo, useState, useCallback, useEffect } from "react"
|
import { useMemo, useState, useCallback, useEffect } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
import { StatusDot } from "@/components/status-dot"
|
import { StatusDot } from "@/components/status-dot"
|
||||||
import { Flag } from "@/components/flag"
|
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 { Card } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -35,42 +41,6 @@ import { toast } from "sonner"
|
|||||||
|
|
||||||
type FilterRouterSyncStatus = "synced" | "drift" | "missing"
|
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 newId() { return `r${Date.now()}-${Math.random().toString(36).slice(2, 6)}` }
|
||||||
function innerIpToGateway(ip: string) { return ip.split("/")[0] }
|
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> = {
|
const COMMUNITY_NAMES: Record<string, string> = {
|
||||||
"65001:100": "youtube-bypass",
|
"65001:100": "youtube-bypass",
|
||||||
"65001:200": "streaming-eu",
|
"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 ─────────────────────────────────────────────────────────────────
|
// ── rule sheet ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface RuleForm {
|
interface RuleForm {
|
||||||
@@ -1496,8 +1291,6 @@ function CopyRulesSheet({
|
|||||||
|
|
||||||
// ── page ───────────────────────────────────────────────────────────────────────
|
// ── page ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type SortKey = "community" | "gateway" | "description"
|
|
||||||
|
|
||||||
interface BackendServer {
|
interface BackendServer {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
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() {
|
export default function FiltersPage() {
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const evo = useEvoBGP()
|
const evo = useEvoBGP()
|
||||||
@@ -1601,8 +1389,6 @@ export default function FiltersPage() {
|
|||||||
}, [isLive, apiFetch])
|
}, [isLive, apiFetch])
|
||||||
const [selectedServerId, setSelectedServerId] = useState("srv1")
|
const [selectedServerId, setSelectedServerId] = useState("srv1")
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("community")
|
|
||||||
const [sortAsc, setSortAsc] = useState(true)
|
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
|
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
|
||||||
const [sheetInitial, setSheetInitial]= useState<RuleForm>(emptyForm())
|
const [sheetInitial, setSheetInitial]= useState<RuleForm>(emptyForm())
|
||||||
@@ -1707,24 +1493,14 @@ export default function FiltersPage() {
|
|||||||
|
|
||||||
const filteredRules = useMemo(() => {
|
const filteredRules = useMemo(() => {
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
const list = q
|
if (!q) return currentRules
|
||||||
? currentRules.filter(r =>
|
return currentRules.filter((r) =>
|
||||||
r.community.includes(q) ||
|
r.community.includes(q) ||
|
||||||
(communityNameMap[r.community] ?? "").toLowerCase().includes(q) ||
|
(communityNameMap[r.community] ?? "").toLowerCase().includes(q) ||
|
||||||
r.gateway.includes(q) ||
|
r.gateway.includes(q) ||
|
||||||
r.description.toLowerCase().includes(q)
|
r.description.toLowerCase().includes(q),
|
||||||
)
|
)
|
||||||
: [...currentRules]
|
}, [currentRules, search, communityNameMap])
|
||||||
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])
|
|
||||||
|
|
||||||
const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => {
|
const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => {
|
||||||
setRouterCompare(rc => (rc && rc.serverId === serverId ? null : rc))
|
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) {
|
if (!selectedServer) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
<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 */}
|
{/* selected server header */}
|
||||||
<div className="flex items-center gap-2.5 px-4 py-3 border-b bg-muted/10">
|
<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 ? (
|
{currentRules.length === 0 ? (
|
||||||
/* empty state */
|
/* empty state */
|
||||||
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
|
<EmptyState
|
||||||
<NetworkIcon className="size-8 text-muted-foreground/20" />
|
icon={<NetworkIcon className="size-4" />}
|
||||||
<div>
|
title="Нет правил фильтрации"
|
||||||
<p className="text-sm font-medium text-muted-foreground">Нет правил фильтрации</p>
|
description={
|
||||||
<p className="text-xs text-muted-foreground/60 mt-1">
|
(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId))
|
||||||
{(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId))
|
|
||||||
? "Добавьте правило: BGP community → GRE-шлюз"
|
? "Добавьте правило: BGP community → GRE-шлюз"
|
||||||
: "Сначала добавьте GRE-туннели для этого сервера"}
|
: "Сначала добавьте GRE-туннели для этого сервера"
|
||||||
</p>
|
}
|
||||||
</div>
|
action={
|
||||||
{(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId)) && (
|
(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId)) ? (
|
||||||
<Button size="sm" onClick={openCreate}>
|
<Button size="sm" onClick={openCreate}>
|
||||||
<PlusIcon className="size-4" />Добавить правило
|
<PlusIcon className="size-4" />Добавить правило
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : undefined
|
||||||
</div>
|
}
|
||||||
|
className="py-20"
|
||||||
|
/>
|
||||||
) : filteredRules.length === 0 ? (
|
) : filteredRules.length === 0 ? (
|
||||||
/* no search results */
|
/* no search results */
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
|
<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>
|
<p className="text-sm">Ничего не найдено</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<FiltersDataGrid
|
||||||
{/* table header with sort */}
|
rules={search ? filteredRules : currentRules}
|
||||||
<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}
|
tunnelsList={allTunnels}
|
||||||
serversList={allServers}
|
serversList={allServers}
|
||||||
communityNameMap={communityNameMap}
|
communityNameMap={communityNameMap}
|
||||||
recursiveRoutes={recRoutesByServer[selectedServerId] ?? []}
|
recursiveRoutes={recRoutesByServer[selectedServerId] ?? []}
|
||||||
routerSyncStatus={
|
routerSyncByCommunity={
|
||||||
!isLive
|
!isLive || !routerCompare || routerCompare.serverId !== selectedServerId
|
||||||
? undefined
|
|
||||||
: !routerCompare || routerCompare.serverId !== selectedServerId
|
|
||||||
? null
|
? null
|
||||||
: routerCompare.byCommunity[rule.community.trim()] ?? null
|
: routerCompare.byCommunity
|
||||||
}
|
}
|
||||||
onEdit={() => openEdit(rule)}
|
isLive={isLive}
|
||||||
onDelete={() => handleDelete(rule.id)}
|
enableSorting={!!search}
|
||||||
onMoveUp={() => handleMoveUp(currentRules.findIndex(r => r.id === rule.id))}
|
onEdit={openEdit}
|
||||||
onMoveDown={() => handleMoveDown(currentRules.findIndex(r => r.id === rule.id))}
|
onDelete={handleDelete}
|
||||||
|
onMoveUp={(id) => handleMoveUp(currentRules.findIndex((r) => r.id === id))}
|
||||||
|
onMoveDown={(id) => handleMoveDown(currentRules.findIndex((r) => r.id === id))}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* footer hint */}
|
|
||||||
<div className="px-4 py-2 text-[11px] text-muted-foreground/40 flex items-center gap-1.5 border-t">
|
|
||||||
<StarIcon className="size-3 text-amber-400 fill-amber-400 shrink-0" />
|
|
||||||
Последнее правило имеет наивысший приоритет в RouterOS
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* add rule shortcut */}
|
{/* add rule shortcut */}
|
||||||
@@ -2158,7 +1884,7 @@ export default function FiltersPage() {
|
|||||||
<PlusIcon className="size-3.5" />
|
<PlusIcon className="size-3.5" />
|
||||||
Добавить правило для {selectedServer.name}
|
Добавить правило для {selectedServer.name}
|
||||||
</button>
|
</button>
|
||||||
</Card>
|
</DataPageCard>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+68
-255
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
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 { firewallRules, type FirewallRule } from "@/lib/data"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -341,62 +349,7 @@ function fmtHits(n: number): string {
|
|||||||
return String(n)
|
return String(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
// ActionBadge, ChainBadge — из firewall-rules-data-grid
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NativeSelect({ value, onChange, children, className }: {
|
function NativeSelect({ value, onChange, children, className }: {
|
||||||
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
|
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">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Цепочка и действие</SectionTitle>
|
<SectionTitle>Цепочка и действие</SectionTitle>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Цепочка" required>
|
<FormField label="Цепочка" required>
|
||||||
<NativeSelect value={form.chain} onChange={(v) => set("chain", v)}>
|
<NativeSelect value={form.chain} onChange={(v) => set("chain", v)}>
|
||||||
{chainsForGroup.map((c) => <option key={c} value={c}>{c}</option>)}
|
{chainsForGroup.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Действие" required>
|
<FormField label="Действие" required>
|
||||||
<NativeSelect value={form.action} onChange={(v) => set("action", v)}>
|
<NativeSelect value={form.action} onChange={(v) => set("action", v)}>
|
||||||
{actions.map((a) => <option key={a} value={a}>{a}</option>)}
|
{actions.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Matching */}
|
{/* Matching */}
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Условие совпадения</SectionTitle>
|
<SectionTitle>Условие совпадения</SectionTitle>
|
||||||
<Field label="Протокол">
|
<FormField label="Протокол">
|
||||||
<NativeSelect value={form.proto} onChange={(v) => set("proto", v)}>
|
<NativeSelect value={form.proto} onChange={(v) => set("proto", v)}>
|
||||||
{["all","tcp","udp","icmp","gre","esp","ah","ipencap","ospf"].map((p) =>
|
{["all","tcp","udp","icmp","gre","esp","ah","ipencap","ospf"].map((p) =>
|
||||||
<option key={p} value={p}>{p}</option>
|
<option key={p} value={p}>{p}</option>
|
||||||
)}
|
)}
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<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"
|
<Input className="font-mono h-8" placeholder="10.0.0.0/8"
|
||||||
value={form.srcAddrList} onChange={(e) => set("srcAddrList", e.target.value)} />
|
value={form.srcAddrList} onChange={(e) => set("srcAddrList", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Dst-address / Address-list">
|
<FormField label="Dst-address / Address-list">
|
||||||
<Input className="font-mono h-8" placeholder="0.0.0.0/0"
|
<Input className="font-mono h-8" placeholder="0.0.0.0/0"
|
||||||
value={form.dstAddrList} onChange={(e) => set("dstAddrList", e.target.value)} />
|
value={form.dstAddrList} onChange={(e) => set("dstAddrList", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<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="—"
|
<Input className="font-mono h-8" placeholder="—"
|
||||||
value={form.srcPort} onChange={(e) => set("srcPort", e.target.value)} />
|
value={form.srcPort} onChange={(e) => set("srcPort", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Dst-port">
|
<FormField label="Dst-port">
|
||||||
<Input className="font-mono h-8" placeholder="443"
|
<Input className="font-mono h-8" placeholder="443"
|
||||||
value={form.dstPort} onChange={(e) => set("dstPort", e.target.value)} />
|
value={form.dstPort} onChange={(e) => set("dstPort", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<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"
|
<Input className="font-mono h-8" placeholder="wan-msk"
|
||||||
value={form.inIface} onChange={(e) => set("inIface", e.target.value)} />
|
value={form.inIface} onChange={(e) => set("inIface", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Out-interface">
|
<FormField label="Out-interface">
|
||||||
<Input className="font-mono h-8" placeholder="lan"
|
<Input className="font-mono h-8" placeholder="lan"
|
||||||
value={form.outIface} onChange={(e) => set("outIface", e.target.value)} />
|
value={form.outIface} onChange={(e) => set("outIface", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</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"
|
<Input className="font-mono h-8" placeholder="new,established"
|
||||||
value={form.connState} onChange={(e) => set("connState", e.target.value)} />
|
value={form.connState} onChange={(e) => set("connState", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Log + Comment */}
|
{/* Log + Comment */}
|
||||||
@@ -578,18 +531,18 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
|
|||||||
<p className="text-sm font-medium">Log</p>
|
<p className="text-sm font-medium">Log</p>
|
||||||
<p className="text-xs text-muted-foreground">Записывать совпадения в системный лог</p>
|
<p className="text-xs text-muted-foreground">Записывать совпадения в системный лог</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={form.log} onChange={(v) => set("log", v)} />
|
<FormToggle checked={form.log} onChange={(v) => set("log", v)} />
|
||||||
</div>
|
</div>
|
||||||
{form.log && (
|
{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"
|
<Input className="font-mono h-8" placeholder="FW-RULE"
|
||||||
value={form.logPrefix} onChange={(e) => set("logPrefix", e.target.value)} />
|
value={form.logPrefix} onChange={(e) => set("logPrefix", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
<Field label="Комментарий">
|
<FormField label="Комментарий">
|
||||||
<Input className="h-8" placeholder="Описание правила"
|
<Input className="h-8" placeholder="Описание правила"
|
||||||
value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Enabled */}
|
{/* Enabled */}
|
||||||
@@ -598,7 +551,7 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
|
|||||||
<p className="text-sm font-medium">Правило включено</p>
|
<p className="text-sm font-medium">Правило включено</p>
|
||||||
<p className="text-xs text-muted-foreground">Отключённые правила сохраняются, но не применяются</p>
|
<p className="text-xs text-muted-foreground">Отключённые правила сохраняются, но не применяются</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={form.enabled} onChange={(v) => set("enabled", v)} />
|
<FormToggle checked={form.enabled} onChange={(v) => set("enabled", v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CLI preview */}
|
{/* CLI preview */}
|
||||||
@@ -1039,14 +992,14 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<SectionTitle>Название</SectionTitle>
|
<SectionTitle>Название</SectionTitle>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Название сценария" required>
|
<FormField label="Название сценария" required>
|
||||||
<Input className="h-8" placeholder="Блокировка Tor Exit"
|
<Input className="h-8" placeholder="Блокировка Tor Exit"
|
||||||
value={name} onChange={e => setName(e.target.value)} />
|
value={name} onChange={e => setName(e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Описание">
|
<FormField label="Описание">
|
||||||
<Input className="h-8" placeholder="Краткое описание"
|
<Input className="h-8" placeholder="Краткое описание"
|
||||||
value={desc} onChange={e => setDesc(e.target.value)} />
|
value={desc} onChange={e => setDesc(e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1054,7 +1007,7 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<SectionTitle>Тестовый пакет по умолчанию</SectionTitle>
|
<SectionTitle>Тестовый пакет по умолчанию</SectionTitle>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
<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)}>
|
<NativeSelect value={pkt.chain} onChange={v => setP("chain", v)}>
|
||||||
<optgroup label="Полный маршрут">
|
<optgroup label="Полный маршрут">
|
||||||
<option value="forward">forward — транзит</option>
|
<option value="forward">forward — транзит</option>
|
||||||
@@ -1067,40 +1020,40 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
|||||||
.map(c => <option key={c} value={c}>{c}</option>)}
|
.map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Протокол">
|
<FormField label="Протокол">
|
||||||
<NativeSelect value={pkt.proto} onChange={v => setP("proto", v)}>
|
<NativeSelect value={pkt.proto} onChange={v => setP("proto", v)}>
|
||||||
{PROTO_OPTS.map(p => <option key={p} value={p}>{p}</option>)}
|
{PROTO_OPTS.map(p => <option key={p} value={p}>{p}</option>)}
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Conn-state">
|
<FormField label="Conn-state">
|
||||||
<Input className="font-mono h-8" value={pkt.connState}
|
<Input className="font-mono h-8" value={pkt.connState}
|
||||||
placeholder="new" onChange={e => setP("connState", e.target.value)} />
|
placeholder="new" onChange={e => setP("connState", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Src IP">
|
<FormField label="Src IP">
|
||||||
<Input className="font-mono h-8" value={pkt.srcAddr}
|
<Input className="font-mono h-8" value={pkt.srcAddr}
|
||||||
onChange={e => setP("srcAddr", e.target.value)} />
|
onChange={e => setP("srcAddr", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Dst IP">
|
<FormField label="Dst IP">
|
||||||
<Input className="font-mono h-8" value={pkt.dstAddr}
|
<Input className="font-mono h-8" value={pkt.dstAddr}
|
||||||
onChange={e => setP("dstAddr", e.target.value)} />
|
onChange={e => setP("dstAddr", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Dst Port">
|
<FormField label="Dst Port">
|
||||||
<Input className="font-mono h-8" value={pkt.dstPort}
|
<Input className="font-mono h-8" value={pkt.dstPort}
|
||||||
placeholder="443" onChange={e => setP("dstPort", e.target.value)} />
|
placeholder="443" onChange={e => setP("dstPort", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="In-interface">
|
<FormField label="In-interface">
|
||||||
<Input className="font-mono h-8" value={pkt.inIface}
|
<Input className="font-mono h-8" value={pkt.inIface}
|
||||||
placeholder="lan" onChange={e => setP("inIface", e.target.value)} />
|
placeholder="lan" onChange={e => setP("inIface", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Out-interface">
|
<FormField label="Out-interface">
|
||||||
<Input className="font-mono h-8" value={pkt.outIface}
|
<Input className="font-mono h-8" value={pkt.outIface}
|
||||||
placeholder="wan-msk" onChange={e => setP("outIface", e.target.value)} />
|
placeholder="wan-msk" onChange={e => setP("outIface", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Dst addr-list">
|
<FormField label="Dst addr-list">
|
||||||
<Input className="font-mono h-8" value={pkt.dstAddrList}
|
<Input className="font-mono h-8" value={pkt.dstAddrList}
|
||||||
placeholder="" onChange={e => setP("dstAddrList", e.target.value)} />
|
placeholder="" onChange={e => setP("dstAddrList", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1170,7 +1123,7 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<span className="text-xs text-muted-foreground">Включено</span>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={addRule}><PlusIcon className="size-4" />Добавить</Button>
|
<Button size="sm" onClick={addRule}><PlusIcon className="size-4" />Добавить</Button>
|
||||||
@@ -1181,55 +1134,13 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
|||||||
{/* Rules table */}
|
{/* Rules table */}
|
||||||
{rules.length > 0 ? (
|
{rules.length > 0 ? (
|
||||||
<div className="rounded-lg border overflow-hidden">
|
<div className="rounded-lg border overflow-hidden">
|
||||||
<table className="w-full text-xs">
|
<FirewallScenarioRulesDataGrid
|
||||||
<thead>
|
rules={rules}
|
||||||
<tr className="border-b bg-muted/30 text-muted-foreground">
|
onToggleEnabled={toggleEnabled}
|
||||||
<th className="text-left px-3 py-2 w-6">#</th>
|
onMoveUp={(id) => moveRule(id, -1)}
|
||||||
<th className="text-left px-3 py-2">Цепочка</th>
|
onMoveDown={(id) => moveRule(id, 1)}
|
||||||
<th className="text-left px-3 py-2">Действие</th>
|
onRemove={removeRule}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
!addOpen && (
|
!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() {
|
export default function FirewallPage() {
|
||||||
const [rules, setRules] = useState<FirewallRule[]>(firewallRules)
|
const [rules, setRules] = useState<FirewallRule[]>(firewallRules)
|
||||||
@@ -1970,7 +1783,7 @@ export default function FirewallPage() {
|
|||||||
) : chainGroup === "simulator" ? (
|
) : chainGroup === "simulator" ? (
|
||||||
<SimulatorTab rules={rules} />
|
<SimulatorTab rules={rules} />
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<DataPageCard>
|
||||||
{/* toolbar */}
|
{/* toolbar */}
|
||||||
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
||||||
{/* IP family selector */}
|
{/* IP family selector */}
|
||||||
@@ -2018,8 +1831,8 @@ export default function FirewallPage() {
|
|||||||
<span className="text-sm text-muted-foreground ml-auto">{filteredRules.length} правил</span>
|
<span className="text-sm text-muted-foreground ml-auto">{filteredRules.length} правил</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RulesTable rules={filteredRules} onToggle={toggleRule} onEdit={openEdit} />
|
<FirewallRulesDataGrid rules={filteredRules} onToggle={toggleRule} onEdit={openEdit} />
|
||||||
</Card>
|
</DataPageCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* RouterOS reference */}
|
{/* RouterOS reference */}
|
||||||
|
|||||||
+61
-261
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { 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 { 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 type { GrePool, GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion, Server } from "@/lib/data"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
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) ─────────────────────────────────────────────
|
// ─── Live API (как на /filters) ─────────────────────────────────────────────
|
||||||
|
|
||||||
interface BackendServer {
|
interface BackendServer {
|
||||||
@@ -501,7 +453,7 @@ export default function GrePage() {
|
|||||||
|
|
||||||
{/* ── Tunnels ── */}
|
{/* ── Tunnels ── */}
|
||||||
{pageTab === "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-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">
|
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||||||
{tunnelTabs.map((t) => (
|
{tunnelTabs.map((t) => (
|
||||||
@@ -520,177 +472,25 @@ export default function GrePage() {
|
|||||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} туннелей</span>
|
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} туннелей</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<GreTunnelsDataGrid
|
||||||
<table className="w-full text-sm">
|
tunnels={filtered}
|
||||||
<thead>
|
servers={displayServers}
|
||||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
pools={displayPools}
|
||||||
<th className="text-left font-medium px-5 py-3">Интерфейс / Сервер</th>
|
onCodePreview={setCodePreviewTunnel}
|
||||||
<th className="text-left font-medium px-4 py-3">Эндпоинты</th>
|
/>
|
||||||
<th className="text-left font-medium px-4 py-3">Внутренний IP</th>
|
</DataPageCard>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── IP Pools ── */}
|
{/* ── IP Pools ── */}
|
||||||
{pageTab === "pools" && (
|
{pageTab === "pools" && (
|
||||||
<Card>
|
<DataPageCard>
|
||||||
<div className="flex items-center justify-between px-5 py-3 border-b">
|
<div className="flex items-center justify-between px-5 py-3 border-b">
|
||||||
<span className="text-sm text-muted-foreground">{displayPools.length} пула</span>
|
<span className="text-sm text-muted-foreground">{displayPools.length} пула</span>
|
||||||
<Button size="sm" variant="outline" onClick={() => { setPForm(defaultPoolForm); setPoolOpen(true) }}>
|
<Button size="sm" variant="outline" onClick={() => { setPForm(defaultPoolForm); setPoolOpen(true) }}>
|
||||||
<PlusIcon className="size-4" />Добавить пул
|
<PlusIcon className="size-4" />Добавить пул
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<GrePoolsDataGrid pools={displayPools} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="border-t px-5 py-4">
|
<div className="border-t px-5 py-4">
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-3">Назначения по пулам</p>
|
<p className="text-xs font-medium text-muted-foreground mb-3">Назначения по пулам</p>
|
||||||
@@ -715,7 +515,7 @@ export default function GrePage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</DataPageCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* RouterOS reference */}
|
{/* 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-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Основные</SectionTitle>
|
<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)} />
|
<Input className="font-mono" placeholder="gre-msk-spb" value={tForm.name} onChange={(e) => setT("name", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Сервер (MikroTik)" required>
|
<FormField label="Сервер (MikroTik)" required>
|
||||||
<select value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)}
|
<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">
|
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>
|
<option value="" disabled>Выбрать сервер…</option>
|
||||||
{displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
|
{displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Комментарий">
|
<FormField label="Комментарий">
|
||||||
<Input placeholder="Описание туннеля" value={tForm.comment} onChange={(e) => setT("comment", e.target.value)} />
|
<Input placeholder="Описание туннеля" value={tForm.comment} onChange={(e) => setT("comment", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">Включён</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Эндпоинты</SectionTitle>
|
<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)} />
|
<Input className="font-mono" placeholder="0.0.0.0" value={tForm.localAddress} onChange={(e) => setT("localAddress", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Удалённый адрес" required hint="Внешний IP удалённого MikroTik">
|
<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)} />
|
<Input className="font-mono" placeholder="203.0.113.1" value={tForm.remoteAddress} onChange={(e) => setT("remoteAddress", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Внутренний IP</SectionTitle>
|
<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)}
|
<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">
|
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>
|
<option value="" disabled>Выбрать пул…</option>
|
||||||
{displayPools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) — свободно {p.total - p.allocated} блоков</option>)}
|
{displayPools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) — свободно {p.total - p.allocated} блоков</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</FormField>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<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)} />
|
<Input className="font-mono" placeholder="10.200.0.1/30" value={tForm.localInnerIp} onChange={(e) => setT("localInnerIp", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Удалённый IP" required hint="/ip address на другом конце">
|
<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)} />
|
<Input className="font-mono" placeholder="10.200.0.2/30" value={tForm.remoteInnerIp} onChange={(e) => setT("remoteInnerIp", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -893,12 +693,12 @@ export default function GrePage() {
|
|||||||
<p className="text-sm font-medium">Включить IPsec</p>
|
<p className="text-sm font-medium">Включить IPsec</p>
|
||||||
<p className="text-xs text-muted-foreground">RouterOS автоматически создаст peer, policy и proposal</p>
|
<p className="text-xs text-muted-foreground">RouterOS автоматически создаст peer, policy и proposal</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={tForm.ipsecEnabled} onChange={(v) => setT("ipsecEnabled", v)} />
|
<FormToggle checked={tForm.ipsecEnabled} onChange={(v) => setT("ipsecEnabled", v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tForm.ipsecEnabled && (
|
{tForm.ipsecEnabled && (
|
||||||
<div className="flex flex-col gap-4 pl-4 border-l-2 border-emerald-500/30">
|
<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">
|
<div className="relative">
|
||||||
<Input type={tForm.ipsecShowSecret ? "text" : "password"} className="font-mono pr-9"
|
<Input type={tForm.ipsecShowSecret ? "text" : "password"} className="font-mono pr-9"
|
||||||
placeholder="Минимум 8 символов" value={tForm.ipsecSecret} onChange={(e) => setT("ipsecSecret", e.target.value)} />
|
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" />}
|
{tForm.ipsecShowSecret ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="IKE-версия">
|
<FormField label="IKE-версия">
|
||||||
<SegmentedControl value={tForm.ipsecIkeVersion} onChange={(v) => setT("ipsecIkeVersion", v)}
|
<SegmentedControl value={tForm.ipsecIkeVersion} onChange={(v) => setT("ipsecIkeVersion", v)}
|
||||||
options={[{ value: "ikev1", label: "IKEv1" }, { value: "ikev2", label: "IKEv2 (рек.)" }]} />
|
options={[{ value: "ikev1", label: "IKEv1" }, { value: "ikev2", label: "IKEv2 (рек.)" }]} />
|
||||||
</Field>
|
</FormField>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<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)}
|
<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">
|
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>)}
|
{(Object.entries(ENC_LABELS) as [IpsecEncAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Хеш-алгоритм">
|
<FormField label="Хеш-алгоритм">
|
||||||
<select value={tForm.ipsecAuthAlg} onChange={(e) => setT("ipsecAuthAlg", e.target.value as IpsecAuthAlg)}
|
<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">
|
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>)}
|
{(Object.entries(AUTH_LABELS) as [IpsecAuthAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<Field label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
|
<FormField label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
|
||||||
<select value={tForm.ipsecDhGroup} onChange={(e) => setT("ipsecDhGroup", e.target.value as IpsecDhGroup)}
|
<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">
|
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>)}
|
{(Object.entries(DH_LABELS) as [IpsecDhGroup, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</FormField>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<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)} />
|
<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">
|
<div className="flex items-center justify-between pt-6">
|
||||||
<span className="text-sm font-medium">PFS</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -955,23 +755,23 @@ export default function GrePage() {
|
|||||||
{tForm.showAdvanced && (
|
{tForm.showAdvanced && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<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))} />
|
<Input type="number" className="font-mono" value={tForm.mtu} onChange={(e) => setT("mtu", Number(e.target.value))} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Keepalive, с" hint="0 = откл.">
|
<FormField label="Keepalive, с" hint="0 = откл.">
|
||||||
<Input type="number" className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
|
<Input type="number" className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Попытки">
|
<FormField label="Попытки">
|
||||||
<Input type="number" className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} />
|
<Input type="number" className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<Field label="DSCP">
|
<FormField label="DSCP">
|
||||||
<select value={tForm.dscp} onChange={(e) => setT("dscp", e.target.value)}
|
<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">
|
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>
|
<option value="inherit">inherit</option>
|
||||||
{Array.from({ length: 64 }, (_, i) => <option key={i} value={String(i)}>{i}</option>)}
|
{Array.from({ length: 64 }, (_, i) => <option key={i} value={String(i)}>{i}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</FormField>
|
||||||
{[
|
{[
|
||||||
{ key: "clampTcpMss" as const, label: "Clamp TCP MSS", desc: "Ограничить MSS до MTU туннеля" },
|
{ key: "clampTcpMss" as const, label: "Clamp TCP MSS", desc: "Ограничить MSS до MTU туннеля" },
|
||||||
{ key: "allowFastPath" as const, label: "Allow Fast Path", desc: "Аппаратное ускорение трафика" },
|
{ 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-sm font-medium">{label}</p>
|
||||||
<p className="text-xs text-muted-foreground">{desc}</p>
|
<p className="text-xs text-muted-foreground">{desc}</p>
|
||||||
</div>
|
</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>
|
||||||
))}
|
))}
|
||||||
</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-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Параметры пула</SectionTitle>
|
<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}
|
<Input className="font-mono" placeholder="pool-gre-core" value={pForm.name}
|
||||||
onChange={(e) => setPForm((f) => ({ ...f, name: e.target.value }))} />
|
onChange={(e) => setPForm((f) => ({ ...f, name: e.target.value }))} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Диапазон CIDR" required hint="Блок, из которого будут нарезаться /30 на каждый туннель">
|
<FormField label="Диапазон CIDR" required hint="Блок, из которого будут нарезаться /30 на каждый туннель">
|
||||||
<Input className="font-mono" placeholder="10.200.0.0/24" value={pForm.cidr}
|
<Input className="font-mono" placeholder="10.200.0.0/24" value={pForm.cidr}
|
||||||
onChange={(e) => setPForm((f) => ({ ...f, cidr: e.target.value }))} />
|
onChange={(e) => setPForm((f) => ({ ...f, cidr: e.target.value }))} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Назначение / Комментарий">
|
<FormField label="Назначение / Комментарий">
|
||||||
<Input placeholder="Ядровые межузловые туннели" value={pForm.comment}
|
<Input placeholder="Ядровые межузловые туннели" value={pForm.comment}
|
||||||
onChange={(e) => setPForm((f) => ({ ...f, comment: e.target.value }))} />
|
onChange={(e) => setPForm((f) => ({ ...f, comment: e.target.value }))} />
|
||||||
</Field>
|
</FormField>
|
||||||
{pForm.cidr && /\/\d+$/.test(pForm.cidr) && (() => {
|
{pForm.cidr && /\/\d+$/.test(pForm.cidr) && (() => {
|
||||||
const prefix = parseInt(pForm.cidr.split("/")[1] ?? "0")
|
const prefix = parseInt(pForm.cidr.split("/")[1] ?? "0")
|
||||||
const blocks = prefix <= 30 ? Math.pow(2, 30 - prefix) : 0
|
const blocks = prefix <= 30 ? Math.pow(2, 30 - prefix) : 0
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
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 { ipRanges as mockIpRanges } from "@/lib/data"
|
||||||
import { Button } from "@/components/ui/button"
|
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 { useDataSource } from "@/lib/data-source"
|
||||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function IpRangesPage() {
|
export default function IpRangesPage() {
|
||||||
const { mode } = useDataSource()
|
const { mode } = useDataSource()
|
||||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||||
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
/** При включённом EvoBGP в live локальные моки не показываем — только каталог API (или пусто при загрузке/ошибке). */
|
|
||||||
const useEvoCatalog = mode === "live" && enabled
|
const useEvoCatalog = mode === "live" && enabled
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
@@ -23,13 +28,27 @@ export default function IpRangesPage() {
|
|||||||
return snapshot?.ipRanges ?? []
|
return snapshot?.ipRanges ?? []
|
||||||
}, [useEvoCatalog, loading, snapshot])
|
}, [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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
|
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
|
||||||
actions={
|
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 variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||||
<Button size="sm"><PlusIcon className="size-4" />Добавить диапазон</Button>
|
<Button size="sm"><PlusIcon className="size-4" />Добавить диапазон</Button>
|
||||||
</>
|
</>
|
||||||
@@ -56,58 +75,31 @@ export default function IpRangesPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataPageCard>
|
||||||
data={rows}
|
<DataPageToolbar
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder="Поиск по CIDR, ASN…"
|
searchPlaceholder="Поиск по CIDR, ASN…"
|
||||||
searchKeys={["cidr", "asn", "country", "filter"]}
|
countLabel={`${filtered.length} диапазонов`}
|
||||||
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>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
<IpRangesDataGrid
|
||||||
|
ipRanges={filtered}
|
||||||
|
isLoading={useEvoCatalog && loading && !snapshot}
|
||||||
|
pagination={useEvoCatalog}
|
||||||
|
/>
|
||||||
|
</DataPageCard>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
import { useMemo, useState, useEffect } from "react"
|
import { useMemo, useState, useEffect } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
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 { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Separator } from "@/components/ui/separator"
|
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"
|
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 }) {
|
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
|
||||||
return (
|
return (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
@@ -978,60 +975,14 @@ function NeighborsTab({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className="overflow-hidden">
|
<DataPageCard>
|
||||||
<div className="overflow-x-auto">
|
<OspfNeighborsDataGrid
|
||||||
<table className="w-full text-xs">
|
neighbors={neighbors}
|
||||||
<thead>
|
selectedRouterId={selectedId}
|
||||||
<tr className="border-b bg-muted/40">
|
onSelect={setSelectedId}
|
||||||
{["Роутер", "Интерфейс", "Сосед (Router ID)", "Область", "Состояние", "Cost", "Uptime", "Prio"].map(h => (
|
onHighlight={setHighlightId}
|
||||||
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
|
/>
|
||||||
))}
|
</DataPageCard>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1052,36 +1003,9 @@ function RoutesTab({ routes }: { routes: OspfRoute[] }) {
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Card className="overflow-hidden">
|
<DataPageCard>
|
||||||
<div className="overflow-x-auto">
|
<OspfRoutesDataGrid routes={routes} />
|
||||||
<table className="w-full text-xs">
|
</DataPageCard>
|
||||||
<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>
|
|
||||||
<div className="flex items-center gap-5 flex-wrap px-1">
|
<div className="flex items-center gap-5 flex-wrap px-1">
|
||||||
<span className="text-xs text-muted-foreground">Типы:</span>
|
<span className="text-xs text-muted-foreground">Типы:</span>
|
||||||
{([
|
{([
|
||||||
@@ -1139,66 +1063,9 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{sessions.length > 0 && (
|
{sessions.length > 0 && (
|
||||||
<Card className="overflow-hidden">
|
<DataPageCard>
|
||||||
<div className="overflow-x-auto">
|
<OspfBfdDataGrid sessions={sessions} />
|
||||||
<table className="w-full text-xs">
|
</DataPageCard>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sessions.length > 0 && (
|
{sessions.length > 0 && (
|
||||||
|
|||||||
+30
-135
@@ -2,7 +2,18 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
|
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
|
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
|
||||||
@@ -40,13 +51,8 @@ interface DiagTest {
|
|||||||
source?: "demo" | "live"
|
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 ────────────────────────────────────────────────────────────
|
// ─── tool metadata ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -97,22 +103,7 @@ interface BackendServerRow {
|
|||||||
latency?: number | null
|
latency?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SpeedProbeApiRow {
|
// SpeedProbeApiRow imported from probes-speed-probes-data-grid
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
// ─── 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 }) {
|
function OptionLabel({ children }: { children: React.ReactNode }) {
|
||||||
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
|
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
|
||||||
}
|
}
|
||||||
@@ -476,53 +456,9 @@ function ScheduleSpeedProbesLive({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Card className="overflow-hidden">
|
<DataPageCard>
|
||||||
{rows.length === 0 ? (
|
<ProbesSpeedProbesDataGrid rows={rows} serverName={name} />
|
||||||
<div className="flex flex-col items-center justify-center py-10 gap-2 text-muted-foreground">
|
</DataPageCard>
|
||||||
<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>
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
Данные из коллектора uptime (та же БД, что и дашборд). Редактирование — через настройки мониторинга / API.
|
Данные из коллектора uptime (та же БД, что и дашборд). Редактирование — через настройки мониторинга / API.
|
||||||
</p>
|
</p>
|
||||||
@@ -563,63 +499,22 @@ function ScheduleTab({
|
|||||||
}
|
}
|
||||||
}, [addSrc, addTun, tunnelsForServer])
|
}, [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 (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Card className="overflow-hidden">
|
<DataPageCard>
|
||||||
{rules.length === 0 ? (
|
<ProbesScheduleDataGrid
|
||||||
<div className="flex flex-col items-center justify-center py-10 gap-2 text-muted-foreground">
|
rules={rules}
|
||||||
<ClockIcon className="size-7 opacity-20" />
|
serverOptions={serverOptions}
|
||||||
<p className="text-sm">Нет правил расписания</p>
|
tunnelName={tunnelName}
|
||||||
</div>
|
onToggleEnabled={(id, enabled) =>
|
||||||
) : (
|
setRules((p) => p.map((r) => (r.id === id ? { ...r, enabled } : r)))
|
||||||
<>
|
}
|
||||||
<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">
|
onDelete={(id) => setRules((p) => p.filter((r) => r.id !== id))}
|
||||||
<span />
|
/>
|
||||||
<span>Туннель</span>
|
</DataPageCard>
|
||||||
<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>
|
|
||||||
{showAdd ? (
|
{showAdd ? (
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b flex items-center gap-2 text-sm font-medium">
|
<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.
|
Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={traceUseDns} onChange={setTraceUseDns} />
|
<FormToggle checked={traceUseDns} onChange={setTraceUseDns} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import {
|
||||||
|
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 { Card } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -11,7 +17,7 @@ import { Flag } from "@/components/flag"
|
|||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { servers as mockServers, type Server } from "@/lib/data"
|
import { servers as mockServers, type Server } from "@/lib/data"
|
||||||
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, 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"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
|
||||||
interface BackendServer {
|
interface BackendServer {
|
||||||
@@ -34,25 +40,6 @@ interface GatewayOption {
|
|||||||
status: "up" | "down"
|
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 = [
|
const COUNTRY_OPTIONS = [
|
||||||
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
|
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
|
||||||
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
|
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
|
||||||
@@ -82,13 +69,7 @@ interface RecursiveRouteRow {
|
|||||||
country: string
|
country: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RouteGroup {
|
interface RouteGroup extends RecursiveRouteGroup {}
|
||||||
key: string
|
|
||||||
dstAddress: string
|
|
||||||
routingTable: string
|
|
||||||
comment: string
|
|
||||||
endpoints: RecursiveRouteRow[]
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeApiFetch(backendUrl: string) {
|
function makeApiFetch(backendUrl: string) {
|
||||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
@@ -148,131 +129,6 @@ const emptyForm = (): RouteForm => ({
|
|||||||
endpoints: [newEndpoint()],
|
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 }) {
|
function EndpointCountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const q = query.trim().toUpperCase()
|
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-1 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Основные</SectionTitle>
|
<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)} />
|
<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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -396,26 +252,26 @@ function RouteSheet({
|
|||||||
|
|
||||||
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
|
<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)} />
|
<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">
|
<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)} />
|
<Input type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value) || 1)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Check Gateway">
|
<FormField label="Check Gateway">
|
||||||
<Input className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
|
<Input className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<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)} />
|
<Input type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="T.Scope">
|
<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)} />
|
<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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5 max-h-[180px] overflow-y-auto overflow-x-hidden pr-1">
|
<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">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Параметры</SectionTitle>
|
<SectionTitle>Параметры</SectionTitle>
|
||||||
<Field label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></Field>
|
<FormField label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></FormField>
|
||||||
<Field label="Комментарий">
|
<FormField label="Комментарий">
|
||||||
<Input className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
<Input className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</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>}
|
{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>
|
</div>
|
||||||
@@ -819,37 +675,13 @@ export default function RecursiveRoutesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<RecursiveRoutesDataGrid
|
||||||
<table className="w-full text-sm">
|
groups={groupedRoutes.map((g) => ({ ...g, id: g.key }))}
|
||||||
<thead>
|
expandedKey={expandedGroupKey}
|
||||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
onExpandedChange={setExpandedGroupKey}
|
||||||
<th className="text-left font-medium px-5 py-3">Route / Comment</th>
|
onEdit={openEdit}
|
||||||
<th className="text-left font-medium px-4 py-3">Gateways</th>
|
onDelete={(g) => setRows((prev) => prev.filter((r) => groupKeyOf(r) !== g.key))}
|
||||||
<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">
|
|
||||||
Нет маршрутов в БД для этого сервера. Нажми "Router => DB" для загрузки.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button onClick={openCreate}
|
<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">
|
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" />
|
<PlusIcon className="size-3.5" />
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
|
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { PageHeader } from "@/components/page-header"
|
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 { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -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 }) {
|
function NInput({ value, onChange, min, max }: { value: number; onChange: (v: number) => void; min?: number; max?: number }) {
|
||||||
return (
|
return (
|
||||||
<Input type="number" value={value} min={min} max={max}
|
<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 ─────────────────────────────────────────────────────────
|
// ─── Home Router card ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type HomeTab = "wan-matrix" | "full-routes" | "bgp-community"
|
type HomeTab = "wan-matrix" | "full-routes" | "bgp-community"
|
||||||
@@ -679,18 +353,17 @@ function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying,
|
|||||||
|
|
||||||
{/* Tab content */}
|
{/* Tab content */}
|
||||||
{tab === "wan-matrix" && (
|
{tab === "wan-matrix" && (
|
||||||
<WanMatrix home={home} legs={wanJhLegs} jumpHosts={jumpHosts} pw={settings.pingWeight} />
|
<RouteOptimizerWanMatrixDataGrid home={home} legs={wanJhLegs} jumpHosts={jumpHosts} />
|
||||||
)}
|
)}
|
||||||
{tab === "full-routes" && (
|
{tab === "full-routes" && (
|
||||||
<FullRoutesTable routes={fullRoutes} bestId={bestRoute?.id} />
|
<RouteOptimizerFullRoutesDataGrid routes={fullRoutes} bestId={bestRoute?.id} />
|
||||||
)}
|
)}
|
||||||
{tab === "bgp-community" && (
|
{tab === "bgp-community" && (
|
||||||
<CommRecsTable
|
<RouteOptimizerCommRecsDataGrid
|
||||||
recs={commRecs}
|
recs={commRecs}
|
||||||
homeId={home.id}
|
homeId={home.id}
|
||||||
pinned={pinned} applied={applied} applying={applying}
|
pinned={pinned} applied={applied} applying={applying}
|
||||||
onPin={onPin} onApply={onApply}
|
onPin={onPin} onApply={onApply}
|
||||||
threshold={settings.switchThreshold}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1167,7 +840,7 @@ export default function RouteOptimizerPage() {
|
|||||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Автоприменение</p>
|
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Автоприменение</p>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm">Применять автоматически</span>
|
<span className="text-sm">Применять автоматически</span>
|
||||||
<Toggle checked={settings.autoApply} onChange={v => set("autoApply", v)} />
|
<FormToggle checked={settings.autoApply} onChange={v => set("autoApply", v)} />
|
||||||
</div>
|
</div>
|
||||||
{settings.autoApply && (
|
{settings.autoApply && (
|
||||||
<>
|
<>
|
||||||
@@ -1264,51 +937,14 @@ export default function RouteOptimizerPage() {
|
|||||||
: "нет данных"}
|
: "нет данных"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<RouteOptimizerOspfPreviewDataGrid
|
||||||
<table className="w-full text-xs">
|
rows={(ospfPreview?.interfaces ?? []).map((row) => ({
|
||||||
<thead>
|
id: `${row.interface}-${row.currentCost}-${row.optimalCost}`,
|
||||||
<tr className="border-b bg-muted/30">
|
...row,
|
||||||
{["Интерфейс", "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>
|
error={ospfPreviewError || null}
|
||||||
))}
|
loading={ospfPreviewLoading}
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ospfApplyResult && (
|
{ospfApplyResult && (
|
||||||
|
|||||||
+154
-466
@@ -1,8 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Fragment, useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
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 { servers as initialServers } from "@/lib/data"
|
||||||
import type { ServerType, Server, WanUplink } from "@/lib/data"
|
import type { ServerType, Server, WanUplink } from "@/lib/data"
|
||||||
import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers"
|
import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers"
|
||||||
@@ -23,6 +28,7 @@ import { useDataSource } from "@/lib/data-source"
|
|||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
@@ -30,56 +36,25 @@ import {
|
|||||||
SheetDescription, SheetFooter, SheetClose,
|
SheetDescription, SheetFooter, SheetClose,
|
||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
import {
|
import {
|
||||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
Stepper,
|
||||||
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
|
StepperContent,
|
||||||
} from "@/components/ui/dropdown-menu"
|
StepperIndicator,
|
||||||
|
StepperItem,
|
||||||
|
StepperNav,
|
||||||
|
StepperPanel,
|
||||||
|
StepperSeparator,
|
||||||
|
StepperTitle,
|
||||||
|
StepperTrigger,
|
||||||
|
} from "@/components/reui/stepper"
|
||||||
import {
|
import {
|
||||||
SearchIcon, RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
||||||
MoreHorizontalIcon, EyeIcon, EyeOffIcon,
|
EyeIcon, EyeOffIcon,
|
||||||
ChevronRightIcon, ChevronDownIcon,
|
ChevronRightIcon, ChevronDownIcon,
|
||||||
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
||||||
ShieldIcon, WifiIcon, PencilIcon, PowerIcon, Trash2Icon, ExternalLinkIcon,
|
ShieldIcon, WifiIcon,
|
||||||
HomeIcon, ServerIcon, NetworkIcon,
|
HomeIcon, ServerIcon, NetworkIcon,
|
||||||
} from "lucide-react"
|
} 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 ───────────────────────────────────────────────────────────────
|
// ─── Countries ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const COUNTRIES = [
|
const COUNTRIES = [
|
||||||
@@ -97,90 +72,8 @@ const COUNTRIES = [
|
|||||||
{ code: "NO", label: "Норвегия" },
|
{ 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 ──────────────────────────────────────────────────
|
// ─── 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 ────────────────────────────────────────────────────────────
|
// ─── Country field ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function CountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
function CountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
@@ -267,30 +160,30 @@ function WanUplinkEditor({ wans, onChange }: {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<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"
|
<Input className="h-8 font-mono text-xs" placeholder="WAN1-RT"
|
||||||
value={wan.name} onChange={e => updateWan(wan.id, { name: e.target.value })} />
|
value={wan.name} onChange={e => updateWan(wan.id, { name: e.target.value })} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Интерфейс">
|
<FormField label="Интерфейс">
|
||||||
<Input className="h-8 font-mono text-xs" placeholder="ether1"
|
<Input className="h-8 font-mono text-xs" placeholder="ether1"
|
||||||
value={wan.iface} onChange={e => updateWan(wan.id, { iface: e.target.value })} />
|
value={wan.iface} onChange={e => updateWan(wan.id, { iface: e.target.value })} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Провайдер (ISP)">
|
<FormField label="Провайдер (ISP)">
|
||||||
<Input className="h-8 text-xs" placeholder="Rostelecom"
|
<Input className="h-8 text-xs" placeholder="Rostelecom"
|
||||||
value={wan.isp} onChange={e => updateWan(wan.id, { isp: e.target.value })} />
|
value={wan.isp} onChange={e => updateWan(wan.id, { isp: e.target.value })} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Внешний IP">
|
<FormField label="Внешний IP">
|
||||||
<Input className="h-8 font-mono text-xs" placeholder="94.25.168.1"
|
<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 })} />
|
value={wan.ip} onChange={e => updateWan(wan.id, { ip: e.target.value })} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="↓ Макс. Мбит">
|
<FormField label="↓ Макс. Мбит">
|
||||||
<Input className="h-8 font-mono text-xs" type="number" min={1}
|
<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) })} />
|
value={wan.maxDl} onChange={e => updateWan(wan.id, { maxDl: Number(e.target.value) })} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="↑ Макс. Мбит">
|
<FormField label="↑ Макс. Мбит">
|
||||||
<Input className="h-8 font-mono text-xs" type="number" min={1}
|
<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) })} />
|
value={wan.maxUl} onChange={e => updateWan(wan.id, { maxUl: Number(e.target.value) })} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -328,10 +221,11 @@ export default function ServersPage() {
|
|||||||
const [_backendOk, setBackendOk] = useState(false)
|
const [_backendOk, setBackendOk] = useState(false)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
||||||
|
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [sheetMode, setSheetMode] = useState<SheetMode>("add")
|
const [sheetMode, setSheetMode] = useState<SheetMode>("add")
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
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 [form, setForm] = useState<FormState>(defaultForm)
|
||||||
const [testState, setTestState] = useState<TestState>("idle")
|
const [testState, setTestState] = useState<TestState>("idle")
|
||||||
const [testMsg, setTestMsg] = useState("")
|
const [testMsg, setTestMsg] = useState("")
|
||||||
@@ -368,6 +262,7 @@ export default function ServersPage() {
|
|||||||
function openAdd() {
|
function openAdd() {
|
||||||
setSheetMode("add"); setEditingId(null)
|
setSheetMode("add"); setEditingId(null)
|
||||||
setForm(defaultForm); setTestState("idle"); setTestMsg("")
|
setForm(defaultForm); setTestState("idle"); setTestMsg("")
|
||||||
|
setSheetStep(1)
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,7 +276,7 @@ export default function ServersPage() {
|
|||||||
lanSubnet: s.lanSubnet ?? "",
|
lanSubnet: s.lanSubnet ?? "",
|
||||||
wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [],
|
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
|
// Fetch full server details (including credentials) from backend
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
@@ -549,13 +444,14 @@ export default function ServersPage() {
|
|||||||
// ── derived ──────────────────────────────────────────────────────────────
|
// ── derived ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return serverList.filter(sv => {
|
const base = serverList.filter(sv => {
|
||||||
if (typeFilter !== "all" && sv.type !== typeFilter) return false
|
if (typeFilter !== "all" && sv.type !== typeFilter) return false
|
||||||
if (!search) return true
|
if (!search) return true
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
|
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(() => ({
|
const counts = useMemo(() => ({
|
||||||
all: serverList.length,
|
all: serverList.length,
|
||||||
@@ -615,273 +511,35 @@ export default function ServersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<Card>
|
<DataPageCard>
|
||||||
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
<DataPageToolbar
|
||||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
segmented={{
|
||||||
{tabs.map(tab => (
|
value: typeFilter,
|
||||||
<button key={tab.value} onClick={() => setTypeFilter(tab.value)}
|
onChange: setTypeFilter,
|
||||||
className={cn(
|
options: tabs.map((tab) => ({
|
||||||
"flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors",
|
value: tab.value,
|
||||||
typeFilter === tab.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
label: tab.label,
|
||||||
)}>
|
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
|
||||||
{tab.label}
|
})),
|
||||||
<span className="text-xs tabular-nums opacity-60">
|
}}
|
||||||
{tab.value === "all" ? counts.all : counts[tab.value as ServerType]}
|
filters={advancedFilters}
|
||||||
</span>
|
onFiltersChange={setAdvancedFilters}
|
||||||
</button>
|
filterFields={SERVER_FILTER_FIELDS}
|
||||||
))}
|
search={search}
|
||||||
</div>
|
onSearchChange={setSearch}
|
||||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
|
searchPlaceholder="Поиск по имени, хосту…"
|
||||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
countLabel={`${filtered.length} серверов`}
|
||||||
<input className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
/>
|
||||||
placeholder="Поиск по имени, хосту…" value={search} onChange={e => setSearch(e.target.value)} />
|
<ServersDataGrid
|
||||||
</div>
|
servers={filtered}
|
||||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} серверов</span>
|
isLive={isLive}
|
||||||
</div>
|
pollingIds={pollingIds}
|
||||||
|
onPoll={handlePoll}
|
||||||
<div className="overflow-x-auto">
|
onEdit={openEdit}
|
||||||
<table className="w-full text-sm">
|
onDelete={handleDelete}
|
||||||
<thead>
|
onToggleStatus={handleToggleStatus}
|
||||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
/>
|
||||||
<th className="text-left font-medium px-5 py-3">Имя / Хост</th>
|
</DataPageCard>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -893,18 +551,45 @@ export default function ServersPage() {
|
|||||||
<SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription>
|
<SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription>
|
||||||
</SheetHeader>
|
</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. Основные */}
|
<FormField label="Имя сервера" required hint="Например home-msk-01">
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<SectionTitle>Основные</SectionTitle>
|
|
||||||
|
|
||||||
<Field label="Имя сервера" required hint="Например home-msk-01">
|
|
||||||
<Input className="font-mono" placeholder="home-msk-01"
|
<Input className="font-mono" placeholder="home-msk-01"
|
||||||
value={form.name} onChange={e => set("name", e.target.value)} />
|
value={form.name} onChange={e => set("name", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Тип узла" required>
|
<FormField label="Тип узла" required>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={form.type}
|
value={form.type}
|
||||||
onChange={v => set("type", v)}
|
onChange={v => set("type", v)}
|
||||||
@@ -914,24 +599,24 @@ export default function ServersPage() {
|
|||||||
{ value: "exit-node", label: "Exit Node" },
|
{ value: "exit-node", label: "Exit Node" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<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"
|
<Input className="font-mono uppercase" placeholder="MSK"
|
||||||
value={form.site} onChange={e => set("site", e.target.value.toUpperCase())} />
|
value={form.site} onChange={e => set("site", e.target.value.toUpperCase())} />
|
||||||
</Field>
|
</FormField>
|
||||||
{!isHomeRouter && (
|
{!isHomeRouter && (
|
||||||
<Field label="ASN" hint="Например AS65001">
|
<FormField label="ASN" hint="Например AS65001">
|
||||||
<Input className="font-mono" placeholder="AS65001"
|
<Input className="font-mono" placeholder="AS65001"
|
||||||
value={form.asn} onChange={e => set("asn", e.target.value)} />
|
value={form.asn} onChange={e => set("asn", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
{isHomeRouter && (
|
{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"
|
<Input className="font-mono" placeholder="192.168.10.0/24"
|
||||||
value={form.lanSubnet} onChange={e => set("lanSubnet", e.target.value)} />
|
value={form.lanSubnet} onChange={e => set("lanSubnet", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -939,45 +624,43 @@ export default function ServersPage() {
|
|||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">Включён</span>
|
<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>
|
||||||
</div>
|
</StepperContent>
|
||||||
|
<StepperContent value={2} className="flex flex-col gap-4">
|
||||||
{/* 2. WAN-аплинки (только для home-router) */}
|
|
||||||
{isHomeRouter && (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<SectionTitle>WAN-аплинки</SectionTitle>
|
<SectionTitle>WAN-аплинки</SectionTitle>
|
||||||
|
{!isHomeRouter ? (
|
||||||
|
<p className="text-sm text-muted-foreground">WAN-аплинки доступны только для типа Home Router.</p>
|
||||||
|
) : (
|
||||||
<WanUplinkEditor
|
<WanUplinkEditor
|
||||||
wans={form.wanUplinks}
|
wans={form.wanUplinks}
|
||||||
onChange={wans => set("wanUplinks", wans)}
|
onChange={wans => set("wanUplinks", wans)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</StepperContent>
|
||||||
{/* 3. Подключение (API) */}
|
<StepperContent value={3} className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>
|
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>
|
||||||
|
|
||||||
<Field label="Хост / IP-адрес" required
|
<FormField label="Хост / IP-адрес" required
|
||||||
hint={isHomeRouter
|
hint={isHomeRouter
|
||||||
? "Управляющий LAN-адрес роутера, например 192.168.10.1"
|
? "Управляющий LAN-адрес роутера, например 192.168.10.1"
|
||||||
: "Внешний или управляющий IP-адрес роутера"}>
|
: "Внешний или управляющий IP-адрес роутера"}>
|
||||||
<Input className="font-mono" placeholder={isHomeRouter ? "192.168.10.1" : "203.0.113.1"}
|
<Input className="font-mono" placeholder={isHomeRouter ? "192.168.10.1" : "203.0.113.1"}
|
||||||
value={form.host} onChange={e => set("host", e.target.value)} />
|
value={form.host} onChange={e => set("host", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Протокол">
|
<FormField label="Протокол">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={form.proto}
|
value={form.proto}
|
||||||
onChange={v => { set("proto", v); set("port", v === "https" ? "443" : "80") }}
|
onChange={v => { set("proto", v); set("port", v === "https" ? "443" : "80") }}
|
||||||
options={[{ value: "https", label: "HTTPS" }, { value: "http", label: "HTTP" }]}
|
options={[{ value: "https", label: "HTTPS" }, { value: "http", label: "HTTP" }]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Порт" hint="443 / 80">
|
<FormField label="Порт" hint="443 / 80">
|
||||||
<Input className="font-mono" placeholder="443"
|
<Input className="font-mono" placeholder="443"
|
||||||
value={form.port} onChange={e => set("port", e.target.value)} />
|
value={form.port} onChange={e => set("port", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -988,13 +671,13 @@ export default function ServersPage() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">Отключить для self-signed сертификатов</p>
|
<p className="text-xs text-muted-foreground">Отключить для self-signed сертификатов</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={form.verifySsl} onChange={v => set("verifySsl", v)} />
|
<FormToggle checked={form.verifySsl} onChange={v => set("verifySsl", v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Путь API">
|
<FormField label="Путь API">
|
||||||
<Input className="font-mono" placeholder="/rest"
|
<Input className="font-mono" placeholder="/rest"
|
||||||
value={form.apiPath} onChange={e => set("apiPath", e.target.value)} />
|
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">
|
<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>
|
<p className="font-medium text-foreground mb-1">RouterOS 7.1+ REST API</p>
|
||||||
@@ -1008,12 +691,12 @@ export default function ServersPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Имя пользователя" required hint="Пользователь RouterOS с доступом к API">
|
<FormField label="Имя пользователя" required hint="Пользователь RouterOS с доступом к API">
|
||||||
<Input className="font-mono" placeholder="api-user"
|
<Input className="font-mono" placeholder="api-user"
|
||||||
value={form.username} onChange={e => set("username", e.target.value)} />
|
value={form.username} onChange={e => set("username", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Пароль" required>
|
<FormField label="Пароль" required>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input type={form.showPassword ? "text" : "password"}
|
<Input type={form.showPassword ? "text" : "password"}
|
||||||
className="font-mono pr-9" placeholder="Пароль пользователя RouterOS"
|
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" />}
|
{form.showPassword ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" className="w-fit 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>
|
</div>
|
||||||
</div>
|
</StepperContent>
|
||||||
|
<StepperContent value={4} className="flex flex-col gap-4">
|
||||||
{/* 4. Дополнительно */}
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}
|
<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">
|
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" />}
|
{form.showAdvanced ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
|
||||||
@@ -1059,34 +740,41 @@ export default function ServersPage() {
|
|||||||
{form.showAdvanced && (
|
{form.showAdvanced && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="SSH-порт">
|
<FormField label="SSH-порт">
|
||||||
<Input type="number" className="font-mono" value={form.sshPort}
|
<Input type="number" className="font-mono" value={form.sshPort}
|
||||||
onChange={e => set("sshPort", Number(e.target.value))} />
|
onChange={e => set("sshPort", Number(e.target.value))} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Winbox-порт">
|
<FormField label="Winbox-порт">
|
||||||
<Input type="number" className="font-mono" value={form.winboxPort}
|
<Input type="number" className="font-mono" value={form.winboxPort}
|
||||||
onChange={e => set("winboxPort", Number(e.target.value))} />
|
onChange={e => set("winboxPort", Number(e.target.value))} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<Field label="Таймаут соединения, с">
|
<FormField label="Таймаут соединения, с">
|
||||||
<Input type="number" className="font-mono" value={form.timeout}
|
<Input type="number" className="font-mono" value={form.timeout}
|
||||||
onChange={e => set("timeout", Number(e.target.value))} />
|
onChange={e => set("timeout", Number(e.target.value))} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Комментарий">
|
<FormField label="Комментарий">
|
||||||
<Input placeholder="Описание или заметка" value={form.comment}
|
<Input placeholder="Описание или заметка" value={form.comment}
|
||||||
onChange={e => set("comment", e.target.value)} />
|
onChange={e => set("comment", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</StepperContent>
|
||||||
|
</StepperPanel>
|
||||||
</div>
|
</Stepper>
|
||||||
|
|
||||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
|
||||||
<Button className="flex-1" onClick={handleSave}>
|
{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" ? "Сохранить" : "Добавить сервер"}
|
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
+57
-217
@@ -1,8 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormField, FormToggle } from "@/components/form-kit"
|
||||||
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import {
|
import {
|
||||||
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
||||||
} from "@/components/ui/card"
|
} from "@/components/ui/card"
|
||||||
@@ -24,6 +26,9 @@ import {
|
|||||||
ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon, LoaderCircleIcon,
|
ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon, LoaderCircleIcon,
|
||||||
DownloadIcon, UploadIcon,
|
DownloadIcon, UploadIcon,
|
||||||
} from "lucide-react"
|
} 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 { cn } from "@/lib/utils"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
|
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
|
||||||
@@ -162,17 +167,6 @@ function makeApiFetch(backendUrl: string) {
|
|||||||
|
|
||||||
// ─── small components ─────────────────────────────────────────────────────────
|
// ─── 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 }) {
|
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-4 py-3.5">
|
<div className="flex items-center justify-between gap-4 py-3.5">
|
||||||
@@ -413,24 +407,24 @@ function UserSheet({ open, user, onSave, onClose }: {
|
|||||||
{tab === "profile" && (
|
{tab === "profile" && (
|
||||||
<div className="px-5 py-5 flex flex-col gap-4">
|
<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)}
|
<Input value={form.name} onChange={e => setField("name", e.target.value)}
|
||||||
placeholder="Иван Иванов" className="h-9" />
|
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)}
|
<Input value={form.login} onChange={e => setField("login", e.target.value)}
|
||||||
placeholder="i.ivanov" className="h-9 font-mono" />
|
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)}
|
<Input value={form.email} onChange={e => setField("email", e.target.value)}
|
||||||
placeholder="[email protected]" type="email" className="h-9" />
|
placeholder="[email protected]" type="email" className="h-9" />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<Field label="Роль">
|
<FormField label="Роль">
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{(["viewer", "operator", "admin"] as Role[]).map(r => (
|
{(["viewer", "operator", "admin"] as Role[]).map(r => (
|
||||||
<button key={r} type="button" onClick={() => setRole(r)}
|
<button key={r} type="button" onClick={() => setRole(r)}
|
||||||
@@ -453,18 +447,18 @@ function UserSheet({ open, user, onSave, onClose }: {
|
|||||||
? "Управление инфраструктурой согласно выданным правам"
|
? "Управление инфраструктурой согласно выданным правам"
|
||||||
: "Только просмотр согласно выданным правам"}
|
: "Только просмотр согласно выданным правам"}
|
||||||
</p>
|
</p>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<Field label="Статус учётной записи">
|
<FormField label="Статус учётной записи">
|
||||||
<div className="flex items-center gap-3">
|
<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")}>
|
<span className={cn("text-xs font-medium", form.active ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground")}>
|
||||||
{form.active ? "Активна" : "Заблокирована"}
|
{form.active ? "Активна" : "Заблокирована"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -577,100 +571,14 @@ function UserSheet({ open, user, onSave, onClose }: {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* table header */}
|
<SubusersDataGrid
|
||||||
{form.subUsers.length > 0 && (
|
subUsers={form.subUsers}
|
||||||
<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"
|
servers={servers}
|
||||||
style={{ gridTemplateColumns: "1fr 130px 120px 90px 36px 32px" }}>
|
revealedIds={revealedIds}
|
||||||
<span>Логин / описание</span>
|
onToggleReveal={toggleReveal}
|
||||||
<span>Пароль</span>
|
onToggleActive={toggleSubUser}
|
||||||
<span>JH-серверы</span>
|
onRemove={removeSubUser}
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* inline add form */}
|
{/* inline add form */}
|
||||||
{addSubOpen ? (
|
{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 ───────────────────────────────────────────────────────────
|
// ─── delete confirm ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function DatabaseRestoreConfirm({
|
function DatabaseRestoreConfirm({
|
||||||
@@ -900,7 +792,7 @@ export default function SettingsPage() {
|
|||||||
const [dbBackupBusy, setDbBackupBusy] = useState(false)
|
const [dbBackupBusy, setDbBackupBusy] = useState(false)
|
||||||
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
|
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
|
||||||
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
|
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
|
||||||
const dbRestoreInputRef = useRef<HTMLInputElement>(null)
|
const [dbRestoreDialogOpen, setDbRestoreDialogOpen] = useState(false)
|
||||||
|
|
||||||
// notifications
|
// notifications
|
||||||
const [notifEmail, setNotifEmail] = useState(true)
|
const [notifEmail, setNotifEmail] = useState(true)
|
||||||
@@ -1031,7 +923,6 @@ export default function SettingsPage() {
|
|||||||
await restoreSystemDatabaseBackup(backendUrl, dbRestoreFile)
|
await restoreSystemDatabaseBackup(backendUrl, dbRestoreFile)
|
||||||
toast.success("База приложения восстановлена")
|
toast.success("База приложения восстановлена")
|
||||||
setDbRestoreFile(null)
|
setDbRestoreFile(null)
|
||||||
if (dbRestoreInputRef.current) dbRestoreInputRef.current.value = ""
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Не удалось восстановить базу")
|
toast.error(e instanceof Error ? e.message : "Не удалось восстановить базу")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1230,30 +1121,16 @@ export default function SettingsPage() {
|
|||||||
description="Полностью заменяет текущую базу SQLite"
|
description="Полностью заменяет текущую базу SQLite"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex flex-col items-end gap-2">
|
||||||
<Input
|
|
||||||
ref={dbRestoreInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".db,.sqlite,.sqlite3,application/octet-stream"
|
|
||||||
className="hidden"
|
|
||||||
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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8"
|
className="h-8"
|
||||||
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
|
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
|
||||||
onClick={() => dbRestoreInputRef.current?.click()}
|
onClick={() => setDbRestoreDialogOpen(true)}
|
||||||
>
|
>
|
||||||
<UploadIcon className="size-4" />
|
<UploadIcon className="size-4" />
|
||||||
Выбрать файл
|
Выбрать файл
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
{dbRestoreFile && (
|
{dbRestoreFile && (
|
||||||
<p className="text-xs text-muted-foreground max-w-[220px] text-right break-all">{dbRestoreFile.name}</p>
|
<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"
|
label="Подставлять данные EvoBGP"
|
||||||
description="На страницах Домены, IP-диапазоны, ASN и Communities вместо моков из lib/data"
|
description="На страницах Домены, IP-диапазоны, ASN и Communities вместо моков из lib/data"
|
||||||
>
|
>
|
||||||
<Toggle
|
<FormToggle
|
||||||
checked={evoEnabledDraft}
|
checked={evoEnabledDraft}
|
||||||
onChange={(v) => setEvoEnabledDraft(v)}
|
onChange={(v) => setEvoEnabledDraft(v)}
|
||||||
/>
|
/>
|
||||||
@@ -1496,15 +1373,15 @@ export default function SettingsPage() {
|
|||||||
<CardHeader><CardTitle className="text-base">Каналы уведомлений</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-base">Каналы уведомлений</CardTitle></CardHeader>
|
||||||
<CardContent className="divide-y px-5">
|
<CardContent className="divide-y px-5">
|
||||||
<SettingRow label="Email" description="Отправка уведомлений на [email protected]">
|
<SettingRow label="Email" description="Отправка уведомлений на [email protected]">
|
||||||
<Toggle checked={notifEmail} onChange={setNotifEmail} />
|
<FormToggle checked={notifEmail} onChange={setNotifEmail} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
{notifEmail && <div className="py-3"><Input className="text-sm h-8" defaultValue="[email protected]" /></div>}
|
{notifEmail && <div className="py-3"><Input className="text-sm h-8" defaultValue="[email protected]" /></div>}
|
||||||
<SettingRow label="Slack" description="Webhook-интеграция с каналом #alerts">
|
<SettingRow label="Slack" description="Webhook-интеграция с каналом #alerts">
|
||||||
<Toggle checked={notifSlack} onChange={setNotifSlack} />
|
<FormToggle checked={notifSlack} onChange={setNotifSlack} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
{notifSlack && <div className="py-3"><Input className="text-sm h-8 font-mono" placeholder="https://hooks.slack.com/…" /></div>}
|
{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">
|
<SettingRow label="Webhook" description="POST-запрос на произвольный endpoint">
|
||||||
<Toggle checked={notifWh} onChange={setNotifWh} />
|
<FormToggle checked={notifWh} onChange={setNotifWh} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
{notifWh && <div className="py-3"><Input className="text-sm h-8 font-mono" defaultValue="https://hooks.example.com/routerlists" /></div>}
|
{notifWh && <div className="py-3"><Input className="text-sm h-8 font-mono" defaultValue="https://hooks.example.com/routerlists" /></div>}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -1513,16 +1390,16 @@ export default function SettingsPage() {
|
|||||||
<CardHeader><CardTitle className="text-base">Триггеры</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-base">Триггеры</CardTitle></CardHeader>
|
||||||
<CardContent className="divide-y px-5">
|
<CardContent className="divide-y px-5">
|
||||||
<SettingRow label="Деградация узла" description="Потери пакетов > 5% или RTT > 100мс">
|
<SettingRow label="Деградация узла" description="Потери пакетов > 5% или RTT > 100мс">
|
||||||
<Toggle checked={notifDegr} onChange={setNotifDegr} />
|
<FormToggle checked={notifDegr} onChange={setNotifDegr} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow label="Узел ушёл offline">
|
<SettingRow label="Узел ушёл offline">
|
||||||
<Toggle checked={notifOffline} onChange={setNotifOffline} />
|
<FormToggle checked={notifOffline} onChange={setNotifOffline} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow label="Падение BGP-сессии">
|
<SettingRow label="Падение BGP-сессии">
|
||||||
<Toggle checked={notifBgp} onChange={setNotifBgp} />
|
<FormToggle checked={notifBgp} onChange={setNotifBgp} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow label="Просроченный бэкап" description="Если последний бэкап старше 2 дней">
|
<SettingRow label="Просроченный бэкап" description="Если последний бэкап старше 2 дней">
|
||||||
<Toggle checked={notifBackup} onChange={setNotifBackup} />
|
<FormToggle checked={notifBackup} onChange={setNotifBackup} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1601,66 +1478,17 @@ export default function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* access summary */}
|
{/* access summary */}
|
||||||
<Card className="overflow-hidden gap-0 py-0">
|
<DataPageCard>
|
||||||
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
||||||
<UserIcon className="size-4 text-muted-foreground shrink-0" />
|
<UserIcon className="size-4 text-muted-foreground shrink-0" />
|
||||||
<span className="text-sm font-medium">Сводка прав доступа</span>
|
<span className="text-sm font-medium">Сводка прав доступа</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<SettingsAccessSummaryDataGrid
|
||||||
<table className="w-full text-sm">
|
users={users}
|
||||||
<thead>
|
servers={servers}
|
||||||
<tr className="border-b text-[11px] text-muted-foreground bg-muted/30">
|
allSectionsCount={ALL_SECTIONS.length}
|
||||||
<th className="text-left font-medium px-4 py-2">Пользователь</th>
|
/>
|
||||||
<th className="text-left font-medium px-4 py-2">Разделы</th>
|
</DataPageCard>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1741,7 +1569,7 @@ export default function SettingsPage() {
|
|||||||
<CardContent className="divide-y px-5">
|
<CardContent className="divide-y px-5">
|
||||||
<SettingRow label="Двухфакторная аутентификация (MFA)"
|
<SettingRow label="Двухфакторная аутентификация (MFA)"
|
||||||
description="TOTP / Authenticator app для всех администраторов">
|
description="TOTP / Authenticator app для всех администраторов">
|
||||||
<Toggle checked={mfa} onChange={setMfa} />
|
<FormToggle checked={mfa} onChange={setMfa} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow label="Тайм-аут сессии (мин)" description="Автоматический выход при бездействии">
|
<SettingRow label="Тайм-аут сессии (мин)" description="Автоматический выход при бездействии">
|
||||||
<Input className="w-20 h-8 text-sm" value={sessMin} onChange={e => setSessMin(e.target.value)} />
|
<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">
|
<CardContent className="divide-y px-5">
|
||||||
<SettingRow label="Расширенный журнал аудита"
|
<SettingRow label="Расширенный журнал аудита"
|
||||||
description="Записывать все изменения конфигурации с указанием пользователя и IP">
|
description="Записывать все изменения конфигурации с указанием пользователя и IP">
|
||||||
<Toggle checked={auditLog} onChange={setAuditLog} />
|
<FormToggle checked={auditLog} onChange={setAuditLog} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1853,7 +1681,6 @@ export default function SettingsPage() {
|
|||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (dbRestoreBusy) return
|
if (dbRestoreBusy) return
|
||||||
setDbRestoreFile(null)
|
setDbRestoreFile(null)
|
||||||
if (dbRestoreInputRef.current) dbRestoreInputRef.current.value = ""
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1864,6 +1691,19 @@ export default function SettingsPage() {
|
|||||||
onCancel={() => setDeleteTarget(null)}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-405
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useMemo, useEffect, useRef, useCallback } from "react"
|
import { useState, useMemo, useEffect, useRef, useCallback } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -9,6 +10,12 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
|||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { StatusDot } from "@/components/status-dot"
|
import { StatusDot } from "@/components/status-dot"
|
||||||
import { Sparkline } from "@/components/sparkline"
|
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 { PING_PROBE_WARN_RTT_MS } from "@/lib/ping-probe"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { servers as mockServers, pingProbes as INIT_PROBES, filters, type Server, type Filter } from "@/lib/data"
|
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 ──────────────────────────────────────────────────────────
|
// ── 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" }) {
|
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
|
||||||
return (
|
return (
|
||||||
<span className={cn(
|
<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 */
|
/** Активный интерфейс RouterOS: не disabled и running */
|
||||||
function isActiveRouterOsInterface(i: { running?: boolean; disabled?: boolean }): boolean {
|
function isActiveRouterOsInterface(i: { running?: boolean; disabled?: boolean }): boolean {
|
||||||
return i.running === true && i.disabled !== true
|
return i.running === true && i.disabled !== true
|
||||||
@@ -323,36 +303,6 @@ function interfaceOptionMatchesSearch(iface: RouterInterfaceOption, raw: string)
|
|||||||
return false
|
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({
|
function ServerPickerCards({
|
||||||
options,
|
options,
|
||||||
selectedId,
|
selectedId,
|
||||||
@@ -910,74 +860,55 @@ function SortIcon({ k, sortKey, sortAsc }: { k: ResSortKey; sortKey: ResSortKey;
|
|||||||
type ResTypeFilter = "all" | "jump-host" | "exit-node" | "home-router"
|
type ResTypeFilter = "all" | "jump-host" | "exit-node" | "home-router"
|
||||||
|
|
||||||
function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerResource[]; serversList: Server[]; liveApi?: boolean }) {
|
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 [resSearch, setResSearch] = useState("")
|
||||||
const [typeFilter, setTypeFilter] = useState<ResTypeFilter>("all")
|
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 hasData = r.hasData !== false
|
||||||
const ramPct = hasData && r.ramTotal > 0 ? Math.round(r.ramUsed / r.ramTotal * 100) : 0
|
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
|
const hddPct = hasData && r.hddTotal > 0 ? Math.round(r.hddUsed / r.hddTotal * 100) : 0
|
||||||
return {
|
return {
|
||||||
...r,
|
...r,
|
||||||
hasData,
|
hasData,
|
||||||
server: serversList.find(s => s.id === r.serverId),
|
server: serversList.find(s => s.id === r.serverId)!,
|
||||||
ramPct,
|
ramPct,
|
||||||
hddPct,
|
hddPct,
|
||||||
}
|
}
|
||||||
}).filter(r => r.server !== undefined), [resources, serversList])
|
}).filter(r => serversList.some(s => s.id === r.serverId)), [resources, serversList])
|
||||||
|
|
||||||
// KPI aggregates (только серверы с реальными сэмплами за окно)
|
// 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 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 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 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 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 highHdd = rows.filter(r => r.server.status === "online" && r.hasData && r.hddPct >= 85).length
|
||||||
|
|
||||||
// Alerts
|
// Alerts
|
||||||
const alerts = useMemo(() =>
|
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],
|
[rows],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Filtered + sorted
|
// Filtered + sorted
|
||||||
const visible = useMemo(() => {
|
const visible = useMemo(() => {
|
||||||
let list = rows
|
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()) {
|
if (resSearch.trim()) {
|
||||||
const q = resSearch.toLowerCase()
|
const q = resSearch.toLowerCase()
|
||||||
list = list.filter(r =>
|
list = list.filter(r =>
|
||||||
r.server!.name.toLowerCase().includes(q) ||
|
r.server.name.toLowerCase().includes(q) ||
|
||||||
r.server!.site.toLowerCase().includes(q) ||
|
r.server.site.toLowerCase().includes(q) ||
|
||||||
r.boardName.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
|
return list
|
||||||
}, [rows, typeFilter, resSearch, sortKey, sortAsc])
|
}, [rows, typeFilter, resSearch])
|
||||||
|
|
||||||
function toggleSort(k: ResSortKey) {
|
|
||||||
if (sortKey === k) setSortAsc(v => !v)
|
|
||||||
else { setSortKey(k); setSortAsc(false) } // default desc for metrics
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportCsv() {
|
function exportCsv() {
|
||||||
const header = ["Сервер", "Тип", "Площадка", "CPU %", "RAM %", "RAM использ.", "RAM всего", "HDD %", "HDD использ.", "HDD всего", "Uptime", "Температура °C", "RouterOS"]
|
const header = ["Сервер", "Тип", "Площадка", "CPU %", "RAM %", "RAM использ.", "RAM всего", "HDD %", "HDD использ.", "HDD всего", "Uptime", "Температура °C", "RouterOS"]
|
||||||
const rowsCsv = visible.map(r => {
|
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),
|
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.hddPct, fmtMB(r.hddUsed), fmtMB(r.hddTotal), fmtUptime(r.uptimeSeconds),
|
||||||
r.temp ?? "", s.os].join(",")
|
r.temp ?? "", s.os].join(",")
|
||||||
@@ -1010,7 +941,7 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
|
|||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{alerts.map(r => {
|
{alerts.map(r => {
|
||||||
const s = r.server!
|
const s = r.server
|
||||||
const issues: string[] = []
|
const issues: string[] = []
|
||||||
if (r.cpu >= 85) issues.push(`CPU ${r.cpu}%`)
|
if (r.cpu >= 85) issues.push(`CPU ${r.cpu}%`)
|
||||||
if (r.ramPct >= 85) issues.push(`RAM ${r.ramPct}%`)
|
if (r.ramPct >= 85) issues.push(`RAM ${r.ramPct}%`)
|
||||||
@@ -1091,195 +1022,9 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Table ─────────────────────────────────────────────────────────── */}
|
{/* ── Table ─────────────────────────────────────────────────────────── */}
|
||||||
<Card className="overflow-hidden">
|
<DataPageCard>
|
||||||
<div className="overflow-x-auto">
|
<UptimeResourcesDataGrid rows={visible} />
|
||||||
<table className="w-full text-sm">
|
</DataPageCard>
|
||||||
<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>
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground/40 text-center">
|
<p className="text-xs text-muted-foreground/40 text-center">
|
||||||
{liveApi
|
{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")}>
|
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">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
{/* enable toggle */}
|
{/* 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 */}
|
{/* route: src → dst */}
|
||||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
<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>
|
<span className="text-xs text-muted-foreground">{speedRuns.length} запусков</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-xs">
|
<UptimeSpeedHistoryDataGrid runs={speedRuns} servers={allServers} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -2824,7 +2470,7 @@ export default function UptimePage() {
|
|||||||
!p.enabled && "opacity-40",
|
!p.enabled && "opacity-40",
|
||||||
)}
|
)}
|
||||||
style={{ gridTemplateColumns: "36px 16px 130px 120px 140px 70px 44px minmax(132px,1fr) 96px 36px 72px" }}>
|
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
|
<StatusDot
|
||||||
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
|
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
|
||||||
pulse={p.status === "up" && p.enabled}
|
pulse={p.status === "up" && p.enabled}
|
||||||
@@ -3002,7 +2648,7 @@ export default function UptimePage() {
|
|||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-5">
|
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-5">
|
||||||
<Field label="Источник">
|
<FormField label="Источник">
|
||||||
<ServerPickerCards
|
<ServerPickerCards
|
||||||
options={selectableSources}
|
options={selectableSources}
|
||||||
selectedId={speedDraft.srcServerId}
|
selectedId={speedDraft.srcServerId}
|
||||||
@@ -3015,9 +2661,9 @@ export default function UptimePage() {
|
|||||||
void loadSpeedInterfaces(nextSrc)
|
void loadSpeedInterfaces(nextSrc)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Назначение">
|
<FormField label="Назначение">
|
||||||
<ServerPickerCards
|
<ServerPickerCards
|
||||||
options={selectableSources.filter((s) => s.id !== speedDraft.srcServerId)}
|
options={selectableSources.filter((s) => s.id !== speedDraft.srcServerId)}
|
||||||
selectedId={speedDraft.dstServerId}
|
selectedId={speedDraft.dstServerId}
|
||||||
@@ -3026,28 +2672,28 @@ export default function UptimePage() {
|
|||||||
void loadSpeedInterfaces(nextDst)
|
void loadSpeedInterfaces(nextDst)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Интерфейс источника">
|
<FormField label="Интерфейс источника">
|
||||||
<InterfacePickerCards
|
<InterfacePickerCards
|
||||||
value={speedDraft.srcInterface}
|
value={speedDraft.srcInterface}
|
||||||
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, srcInterface: v }))}
|
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, srcInterface: v }))}
|
||||||
options={filterActiveInterfaces(speedIfaces[speedDraft.srcServerId] ?? [])}
|
options={filterActiveInterfaces(speedIfaces[speedDraft.srcServerId] ?? [])}
|
||||||
autoLabel="auto"
|
autoLabel="auto"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Интерфейс назначения">
|
<FormField label="Интерфейс назначения">
|
||||||
<InterfacePickerCards
|
<InterfacePickerCards
|
||||||
value={speedDraft.dstInterface}
|
value={speedDraft.dstInterface}
|
||||||
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, dstInterface: v }))}
|
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, dstInterface: v }))}
|
||||||
options={filterActiveInterfaces(speedIfaces[speedDraft.dstServerId] ?? [])}
|
options={filterActiveInterfaces(speedIfaces[speedDraft.dstServerId] ?? [])}
|
||||||
autoLabel="auto"
|
autoLabel="auto"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<Field label="Протокол">
|
<FormField label="Протокол">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={speedDraft.protocol}
|
value={speedDraft.protocol}
|
||||||
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, protocol: v }))}
|
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, protocol: v }))}
|
||||||
@@ -3056,8 +2702,8 @@ export default function UptimePage() {
|
|||||||
{ value: "udp", label: "UDP" },
|
{ value: "udp", label: "UDP" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Direction">
|
<FormField label="Direction">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={speedDraft.direction}
|
value={speedDraft.direction}
|
||||||
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, direction: v }))}
|
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, direction: v }))}
|
||||||
@@ -3067,10 +2713,10 @@ export default function UptimePage() {
|
|||||||
{ value: "receive", label: "rx" },
|
{ value: "receive", label: "rx" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Сек">
|
<FormField label="Сек">
|
||||||
<Input className="h-9" value={speedDraft.durationSec} onChange={(e) => setSpeedDraft((prev) => ({ ...prev, durationSec: e.target.value }))} />
|
<Input className="h-9" value={speedDraft.durationSec} onChange={(e) => setSpeedDraft((prev) => ({ ...prev, durationSec: e.target.value }))} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{speedDraft.srcServerId &&
|
{speedDraft.srcServerId &&
|
||||||
@@ -3169,7 +2815,7 @@ export default function UptimePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field
|
<FormField
|
||||||
label="Источник (кто пингует)"
|
label="Источник (кто пингует)"
|
||||||
hint="— весь каталог, в т.ч. выключенные в инвентаре (Home Router часто «выкл.», но доступен по LAN для ping)"
|
hint="— весь каталог, в т.ч. выключенные в инвентаре (Home Router часто «выкл.», но доступен по LAN для ping)"
|
||||||
>
|
>
|
||||||
@@ -3178,9 +2824,9 @@ export default function UptimePage() {
|
|||||||
selectedId={newSrcId}
|
selectedId={newSrcId}
|
||||||
onSelect={(id) => setNewSrcId(id)}
|
onSelect={(id) => setNewSrcId(id)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Интерфейс источника" hint="(необязательно)">
|
<FormField label="Интерфейс источника" hint="(необязательно)">
|
||||||
<InterfacePickerCards
|
<InterfacePickerCards
|
||||||
value={newSrcInterface}
|
value={newSrcInterface}
|
||||||
onChange={setNewSrcInterface}
|
onChange={setNewSrcInterface}
|
||||||
@@ -3188,25 +2834,25 @@ export default function UptimePage() {
|
|||||||
autoLabel="авто (по маршруту)"
|
autoLabel="авто (по маршруту)"
|
||||||
busy={srcInterfacesBusy}
|
busy={srcInterfacesBusy}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Имя пробы">
|
<FormField label="Имя пробы">
|
||||||
<Input className="h-9 text-sm" placeholder="youtube.com"
|
<Input className="h-9 text-sm" placeholder="youtube.com"
|
||||||
value={newName} onChange={e => setNewName(e.target.value)} />
|
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"
|
<Input className="h-9 text-sm font-mono" placeholder="142.250.74.110"
|
||||||
value={newTarget} onChange={e => setNewTarget(e.target.value)} />
|
value={newTarget} onChange={e => setNewTarget(e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Связанный фильтр" hint="(необязательно)">
|
<FormField label="Связанный фильтр" hint="(необязательно)">
|
||||||
<LinkedFilterPickerCards
|
<LinkedFilterPickerCards
|
||||||
value={newFilter}
|
value={newFilter}
|
||||||
onChange={setNewFilter}
|
onChange={setNewFilter}
|
||||||
items={filters}
|
items={filters}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
|
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
|
||||||
|
|||||||
+17
-139
@@ -4,17 +4,14 @@ import { useMemo, useState } from "react"
|
|||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { vxlanTunnels, servers } from "@/lib/data"
|
import { vxlanTunnels, servers } from "@/lib/data"
|
||||||
import type { VxlanTunnel } from "@/lib/data"
|
import type { VxlanTunnel } from "@/lib/data"
|
||||||
import { Flag } from "@/components/flag"
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
NetworkIcon, PlusIcon, CopyIcon, CheckIcon,
|
||||||
DropdownMenuItem, DropdownMenuSeparator,
|
|
||||||
} from "@/components/ui/dropdown-menu"
|
|
||||||
import {
|
|
||||||
SearchIcon, NetworkIcon, PlusIcon, MoreHorizontalIcon,
|
|
||||||
Trash2Icon, PencilIcon, PowerIcon, CopyIcon, CheckIcon,
|
|
||||||
CodeXmlIcon, LayersIcon,
|
CodeXmlIcon, LayersIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import {
|
import {
|
||||||
@@ -125,100 +122,7 @@ function ExportSheet({ open, tunnel, onClose }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tunnel row ───────────────────────────────────────────────────────────────
|
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
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 default function VxlanPage() {
|
export default function VxlanPage() {
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null)
|
const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null)
|
||||||
@@ -284,45 +188,19 @@ export default function VxlanPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<Card>
|
<DataPageCard>
|
||||||
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
<DataPageToolbar
|
||||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
|
search={search}
|
||||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
onSearchChange={setSearch}
|
||||||
<input
|
searchPlaceholder="Поиск по имени, VNI, серверу…"
|
||||||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
countLabel={`${filtered.length} туннелей`}
|
||||||
placeholder="Поиск по имени, VNI, серверу…"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<VxlanDataGrid
|
||||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} туннелей</span>
|
tunnels={filtered}
|
||||||
</div>
|
servers={servers}
|
||||||
|
onExport={setExportTunnel}
|
||||||
{/* 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">
|
</DataPageCard>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Reference */}
|
{/* Reference */}
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
+21
-235
@@ -3,34 +3,28 @@
|
|||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { servers } from "@/lib/data"
|
import { servers } from "@/lib/data"
|
||||||
import type { WireGuardInterface, WireGuardPeer } from "@/lib/data"
|
import type { WireGuardInterface } from "@/lib/data"
|
||||||
import { Flag } from "@/components/flag"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { Card, CardContent } from "@/components/ui/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 { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||||
SheetDescription, SheetFooter, SheetClose,
|
SheetDescription, SheetFooter, SheetClose,
|
||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
import {
|
import {
|
||||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
|
||||||
DropdownMenuItem, DropdownMenuSeparator,
|
CodeXmlIcon, UsersIcon, ActivityIcon,
|
||||||
} from "@/components/ui/dropdown-menu"
|
CopyIcon, CheckIcon,
|
||||||
import {
|
|
||||||
ShieldCheckIcon, PlusIcon, SearchIcon, KeyRoundIcon,
|
|
||||||
ChevronDownIcon, ChevronRightIcon, MoreHorizontalIcon,
|
|
||||||
PencilIcon, Trash2Icon, PowerIcon, CopyIcon, CheckIcon,
|
|
||||||
CodeXmlIcon, UsersIcon, ActivityIcon, ArrowDownIcon, ArrowUpIcon,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
// ─── collect all WireGuard interfaces from all servers ────────────────────────
|
// ─── collect all WireGuard interfaces from all servers ────────────────────────
|
||||||
|
|
||||||
interface WgIfaceWithServer extends WireGuardInterface {
|
|
||||||
serverId: string
|
|
||||||
serverName: string
|
|
||||||
serverCountry: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectInterfaces(): WgIfaceWithServer[] {
|
function collectInterfaces(): WgIfaceWithServer[] {
|
||||||
const result: WgIfaceWithServer[] = []
|
const result: WgIfaceWithServer[] = []
|
||||||
for (const srv of servers) {
|
for (const srv of servers) {
|
||||||
@@ -48,19 +42,6 @@ function collectInterfaces(): WgIfaceWithServer[] {
|
|||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
// ─── 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 ────────────────────────────────────────────────────────────
|
// ─── RSC generator ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function generateWgRsc(iface: WgIfaceWithServer): string {
|
function generateWgRsc(iface: WgIfaceWithServer): string {
|
||||||
@@ -89,161 +70,6 @@ function generateWgRsc(iface: WgIfaceWithServer): string {
|
|||||||
return lines.join("\n")
|
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 ─────────────────────────────────────────────────────────────
|
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ExportSheet({ open, iface, onClose }: {
|
function ExportSheet({ open, iface, onClose }: {
|
||||||
@@ -310,7 +136,6 @@ export default function WireGuardPage() {
|
|||||||
const allIfaces = useMemo(() => collectInterfaces(), [])
|
const allIfaces = useMemo(() => collectInterfaces(), [])
|
||||||
|
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
|
||||||
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
|
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
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 onlinePeers = allIfaces.reduce((s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length, 0)
|
||||||
const upIfaces = allIfaces.filter((i) => i.status === "up").length
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -384,49 +201,18 @@ export default function WireGuardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search + table */}
|
{/* Search + table */}
|
||||||
<Card>
|
<DataPageCard>
|
||||||
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
<DataPageToolbar
|
||||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[260px]">
|
search={search}
|
||||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
onSearchChange={setSearch}
|
||||||
<input
|
searchPlaceholder="Поиск по имени, серверу, IP…"
|
||||||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
countLabel={`${filtered.length} интерфейсов`}
|
||||||
placeholder="Поиск по имени, серверу, IP…"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<WireguardDataGrid
|
||||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} интерфейсов</span>
|
interfaces={filtered}
|
||||||
</div>
|
onExport={setExportIface}
|
||||||
|
|
||||||
{/* 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)}
|
|
||||||
/>
|
/>
|
||||||
))
|
</DataPageCard>
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* RouterOS reference */}
|
{/* RouterOS reference */}
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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" })
|
||||||
|
}
|
||||||
@@ -39,6 +39,15 @@
|
|||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
--color-card: var(--card);
|
--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-sm: calc(var(--radius) * 0.6);
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
@@ -74,6 +83,15 @@
|
|||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--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 */
|
/* Borders + inputs */
|
||||||
--border: oklch(0.904 0.006 264.0);
|
--border: oklch(0.904 0.006 264.0);
|
||||||
@@ -148,6 +166,15 @@
|
|||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--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%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
|||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
bodyLimit: 512 * 1024 * 1024,
|
bodyLimit: 512 * 1024 * 1024,
|
||||||
|
requestTimeout: 10 * 60 * 1000,
|
||||||
logger: {
|
logger: {
|
||||||
transport: {
|
transport: {
|
||||||
target: "pino-pretty",
|
target: "pino-pretty",
|
||||||
|
|||||||
+3
-1
@@ -21,5 +21,7 @@
|
|||||||
},
|
},
|
||||||
"menuColor": "default",
|
"menuColor": "default",
|
||||||
"menuAccent": "subtle",
|
"menuAccent": "subtle",
|
||||||
"registries": {}
|
"registries": {
|
||||||
|
"@reui": "https://reui.io/r/{style}/{name}.json"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, type ReactNode } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getExpandedRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { InboxIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export interface CompactDataGridColumn<T> {
|
||||||
|
id: string
|
||||||
|
header: string
|
||||||
|
accessorKey?: keyof T & string
|
||||||
|
cell?: (row: T) => ReactNode
|
||||||
|
enableSorting?: boolean
|
||||||
|
headerClassName?: string
|
||||||
|
cellClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompactDataGridProps<T extends { id: string }> {
|
||||||
|
data: T[]
|
||||||
|
columns: CompactDataGridColumn<T>[]
|
||||||
|
isLoading?: boolean
|
||||||
|
emptyTitle?: string
|
||||||
|
emptyDescription?: string
|
||||||
|
onRowClick?: (row: T) => void
|
||||||
|
getExpandedContent?: (row: T) => ReactNode
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompactDataGrid<T extends { id: string }>({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
isLoading,
|
||||||
|
emptyTitle = "Нет записей",
|
||||||
|
emptyDescription,
|
||||||
|
onRowClick,
|
||||||
|
getExpandedContent,
|
||||||
|
compact = false,
|
||||||
|
}: CompactDataGridProps<T>) {
|
||||||
|
const pad = compact ? "py-2" : DATA_GRID_CELL_PAD
|
||||||
|
const padFirst = compact ? "pl-4 py-2" : DATA_GRID_CELL_PAD_FIRST
|
||||||
|
const padLast = compact ? "pr-4 py-2" : DATA_GRID_CELL_PAD_LAST
|
||||||
|
|
||||||
|
const columnDefs = useMemo<ColumnDef<T>[]>(
|
||||||
|
() =>
|
||||||
|
columns.map((col, index) => ({
|
||||||
|
id: col.id,
|
||||||
|
accessorKey: col.accessorKey ?? col.id,
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title={col.header} className={index === 0 ? "ml-1" : undefined} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (col.cell ? col.cell(row.original) : String(row.getValue(col.id) ?? "—")),
|
||||||
|
enableSorting: col.enableSorting !== false,
|
||||||
|
meta: {
|
||||||
|
headerTitle: col.header,
|
||||||
|
headerClassName:
|
||||||
|
col.headerClassName ??
|
||||||
|
(index === 0 ? padFirst : index === columns.length - 1 ? padLast : pad),
|
||||||
|
cellClassName:
|
||||||
|
col.cellClassName ??
|
||||||
|
(index === 0 ? padFirst : index === columns.length - 1 ? padLast : pad),
|
||||||
|
...(getExpandedContent && index === 0
|
||||||
|
? { expandedContent: getExpandedContent }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
[columns, getExpandedContent, pad, padFirst, padLast],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns: columnDefs,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
...(getExpandedContent ? { getExpandedRowModel: getExpandedRowModel() } : {}),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
...(getExpandedContent ? { getRowCanExpand: () => true } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={data.length}
|
||||||
|
isLoading={isLoading}
|
||||||
|
loadingMode="skeleton"
|
||||||
|
onRowClick={
|
||||||
|
onRowClick ??
|
||||||
|
(getExpandedContent
|
||||||
|
? (row) => table.getRow(row.id).toggleExpanded()
|
||||||
|
: undefined)
|
||||||
|
}
|
||||||
|
emptyMessage={
|
||||||
|
<EmptyState
|
||||||
|
icon={<InboxIcon className="size-4" />}
|
||||||
|
title={emptyTitle}
|
||||||
|
description={emptyDescription}
|
||||||
|
className="border-0 py-10"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CompactDataGrid, type CompactDataGridProps }
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import type { PingProbe, Server, ServerType } from "@/lib/data"
|
||||||
|
import { Flag } from "@/components/flag"
|
||||||
|
import { StatusBadge } from "@/components/status-badge"
|
||||||
|
import { StatusDot } from "@/components/status-dot"
|
||||||
|
import { Sparkline } from "@/components/sparkline"
|
||||||
|
import { CompactDataGrid, type CompactDataGridColumn } from "@/components/data-grids/compact-data-grid"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { FilterIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function formatLossPct(loss: number): string {
|
||||||
|
if (!Number.isFinite(loss)) return "—"
|
||||||
|
return Number.isInteger(loss) ? `${loss}%` : `${loss.toFixed(1)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function TypeChip({ type }: { type: ServerType }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
|
||||||
|
type === "home-router"
|
||||||
|
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||||
|
: type === "jump-host"
|
||||||
|
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
|
||||||
|
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server[] }) {
|
||||||
|
const srv = catalog.find((s) => s.id === probe.srcServerId)
|
||||||
|
const iface = (probe.srcInterface ?? "").trim() || "auto"
|
||||||
|
|
||||||
|
if (!srv) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
|
||||||
|
<span className="mt-1 shrink-0 inline-flex">
|
||||||
|
<StatusDot status="offline" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[13px] font-medium text-muted-foreground truncate">
|
||||||
|
Сервер <span className="font-mono tabular-nums">{probe.srcServerId}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] font-mono text-muted-foreground truncate mt-0.5" title={iface}>
|
||||||
|
{iface}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
|
||||||
|
<span className="mt-1 shrink-0 inline-flex">
|
||||||
|
<StatusDot status={srv.status} pulse={srv.status === "online"} />
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2 min-w-0 flex-1">
|
||||||
|
<Flag code={srv.country} size={16} className="shrink-0 mt-0.5" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<span className="text-[13px] font-medium leading-tight truncate">{srv.name}</span>
|
||||||
|
<TypeChip type={srv.type} />
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-0.5 truncate" title={`Интерфейс: ${iface}`}>
|
||||||
|
<span className="font-mono tabular-nums">{iface}</span>
|
||||||
|
{srv.site && srv.site !== "—" && (
|
||||||
|
<span className="text-muted-foreground/90"> · {srv.site}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardActiveProbesDataGridProps {
|
||||||
|
probes: PingProbe[]
|
||||||
|
catalog: Server[]
|
||||||
|
isLoading?: boolean
|
||||||
|
emptyTitle?: string
|
||||||
|
emptyDescription?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardActiveProbesDataGrid({
|
||||||
|
probes,
|
||||||
|
catalog,
|
||||||
|
isLoading,
|
||||||
|
emptyTitle = "Нет активных проб",
|
||||||
|
emptyDescription,
|
||||||
|
}: DashboardActiveProbesDataGridProps) {
|
||||||
|
const columns = useMemo<CompactDataGridColumn<PingProbe>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "source",
|
||||||
|
header: "Источник",
|
||||||
|
enableSorting: false,
|
||||||
|
cell: (p) => <ProbeSourceCell probe={p} catalog={catalog} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
header: "Проба",
|
||||||
|
accessorKey: "name",
|
||||||
|
cell: (p) => <span className="font-medium">{p.name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "target",
|
||||||
|
header: "Цель",
|
||||||
|
accessorKey: "target",
|
||||||
|
cell: (p) => <span className="font-mono text-xs text-muted-foreground">{p.target}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "filter",
|
||||||
|
header: "Фильтр",
|
||||||
|
accessorKey: "filter",
|
||||||
|
enableSorting: false,
|
||||||
|
cell: (p) => (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs border border-border rounded px-2 py-0.5">
|
||||||
|
<FilterIcon className="size-3 text-muted-foreground" />
|
||||||
|
{p.filter}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rtt",
|
||||||
|
header: "RTT",
|
||||||
|
headerClassName: "text-right",
|
||||||
|
cellClassName: "font-mono text-right",
|
||||||
|
cell: (p) => (p.rtt == null ? "—" : `${p.rtt} мс`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "loss",
|
||||||
|
header: "Потери",
|
||||||
|
headerClassName: "text-right",
|
||||||
|
cellClassName: "font-mono text-right",
|
||||||
|
cell: (p) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
p.loss > 5 ? "text-red-500" : p.loss > 0 ? "text-amber-500" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatLossPct(p.loss)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "series",
|
||||||
|
header: "60с",
|
||||||
|
enableSorting: false,
|
||||||
|
headerClassName: "w-36",
|
||||||
|
cell: (p) => {
|
||||||
|
const sparkColor =
|
||||||
|
p.status === "down"
|
||||||
|
? "hsl(0 84% 60%)"
|
||||||
|
: p.status === "warn"
|
||||||
|
? "hsl(32 94% 44%)"
|
||||||
|
: "hsl(142 76% 36%)"
|
||||||
|
return <Sparkline data={p.series} width={120} height={24} color={sparkColor} />
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Статус",
|
||||||
|
enableSorting: false,
|
||||||
|
cell: (p) => (
|
||||||
|
<StatusBadge
|
||||||
|
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[catalog],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CompactDataGrid
|
||||||
|
data={probes}
|
||||||
|
columns={columns}
|
||||||
|
compact
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyTitle={emptyTitle}
|
||||||
|
emptyDescription={emptyDescription}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DashboardActiveProbesDataGrid, type DashboardActiveProbesDataGridProps }
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import type { SchedulerJobStatusDto } from "@/lib/scheduler-settings"
|
||||||
|
import { FormToggle } from "@/components/form-kit"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { RefreshCwIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export interface SchedulerJobGridRow {
|
||||||
|
id: string
|
||||||
|
jobKey: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
fixedSchedule: boolean
|
||||||
|
enabled: boolean
|
||||||
|
intervalValue: string
|
||||||
|
intervalReadOnly: boolean
|
||||||
|
intervalDisabled: boolean
|
||||||
|
defaultInterval: number
|
||||||
|
job?: SchedulerJobStatusDto
|
||||||
|
onEnabledChange?: (enabled: boolean) => void
|
||||||
|
onIntervalChange: (value: string) => void
|
||||||
|
onRunNow: () => void
|
||||||
|
runNowLoading: boolean
|
||||||
|
saveBusy: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataCollectionSchedulerDataGridProps {
|
||||||
|
rows: SchedulerJobGridRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataCollectionSchedulerDataGrid({ rows }: DataCollectionSchedulerDataGridProps) {
|
||||||
|
const columns = useMemo<ColumnDef<SchedulerJobGridRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "task",
|
||||||
|
accessorKey: "label",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Задача</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="align-top">
|
||||||
|
<span className="font-medium text-sm">{row.original.label}</span>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-0.5 leading-snug">
|
||||||
|
{row.original.description}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground font-mono mt-1">{row.original.jobKey}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD_FIRST, "align-top"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "enabled",
|
||||||
|
accessorKey: "enabled",
|
||||||
|
header: () => (
|
||||||
|
<span className="text-xs font-medium text-muted-foreground text-center block">Вкл</span>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { fixedSchedule, enabled, saveBusy, onEnabledChange } = row.original
|
||||||
|
return (
|
||||||
|
<div className="text-center align-top">
|
||||||
|
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
|
||||||
|
<FormToggle
|
||||||
|
checked={enabled}
|
||||||
|
disabled={fixedSchedule || saveBusy}
|
||||||
|
onChange={(v) => {
|
||||||
|
if (fixedSchedule || saveBusy) return
|
||||||
|
onEnabledChange?.(v)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
size: 56,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: cn(DATA_GRID_CELL_PAD, "align-top") },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "interval",
|
||||||
|
accessorKey: "intervalValue",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Интервал (с)</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original
|
||||||
|
return (
|
||||||
|
<div className="w-28 align-top">
|
||||||
|
<Input
|
||||||
|
value={r.intervalValue}
|
||||||
|
onChange={(e) => r.onIntervalChange(e.target.value)}
|
||||||
|
className="h-8 text-sm tabular-nums"
|
||||||
|
inputMode="numeric"
|
||||||
|
readOnly={r.intervalReadOnly}
|
||||||
|
disabled={r.intervalDisabled}
|
||||||
|
placeholder={String(r.defaultInterval)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: cn(DATA_GRID_CELL_PAD, "align-top") },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lastRun",
|
||||||
|
accessorFn: (row) => row.job?.lastFinishedAt ?? "",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Последний прогон</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const j = row.original.job
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-muted-foreground align-top">
|
||||||
|
{j?.lastFinishedAt ? new Date(j.lastFinishedAt).toLocaleString("ru-RU") : "—"}
|
||||||
|
{j?.lastDurationMs != null && (
|
||||||
|
<span className="block text-[11px]">{j.lastDurationMs} мс</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: cn(DATA_GRID_CELL_PAD, "align-top") },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
accessorFn: (row) => row.job?.lastStatus ?? "",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Статус</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const j = row.original.job
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 align-top">
|
||||||
|
{j?.running ? (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
выполняется
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{j?.lastStatus ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"text-[10px]",
|
||||||
|
j.lastStatus === "ok" && "border-emerald-500/40 text-emerald-700 dark:text-emerald-400",
|
||||||
|
j.lastStatus === "error" && "border-destructive/50 text-destructive",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{j.lastStatus}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{j?.lastError ? (
|
||||||
|
<span className="text-[10px] text-destructive max-w-[200px] truncate block" title={j.lastError}>
|
||||||
|
{j.lastError}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: cn(DATA_GRID_CELL_PAD, "align-top") },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "runNow",
|
||||||
|
header: () => (
|
||||||
|
<span className="text-xs font-medium text-muted-foreground text-right block">Сейчас</span>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original
|
||||||
|
return (
|
||||||
|
<div className="text-right align-top">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8"
|
||||||
|
disabled={r.job?.running || r.runNowLoading}
|
||||||
|
onClick={r.onRunNow}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className={cn("size-3.5", r.runNowLoading && "animate-spin")} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "align-top"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: rows,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={rows.length}
|
||||||
|
tableClassNames={{ bodyRow: "group/row hover:bg-muted/40 text-sm" }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DataCollectionSchedulerDataGrid, type DataCollectionSchedulerDataGridProps }
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import type { Domain } from "@/lib/data"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { FilterIcon, GlobeIcon } from "lucide-react"
|
||||||
|
|
||||||
|
interface DomainsDataGridProps {
|
||||||
|
domains: Domain[]
|
||||||
|
isLoading?: boolean
|
||||||
|
pagination?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function DomainsDataGrid({ domains, isLoading, pagination = false }: DomainsDataGridProps) {
|
||||||
|
const columns = useMemo<ColumnDef<Domain>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "domain",
|
||||||
|
accessorKey: "domain",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Домен" className="ml-1" />,
|
||||||
|
cell: ({ row }) => <span className="font-medium">{row.original.domain}</span>,
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Домен",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "resolvedIp",
|
||||||
|
accessorKey: "resolvedIp",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Resolved IP" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{row.original.resolvedIp}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Resolved IP",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "asn",
|
||||||
|
accessorKey: "asn",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="ASN" />,
|
||||||
|
cell: ({ row }) => <span className="font-mono text-xs">{row.original.asn}</span>,
|
||||||
|
meta: {
|
||||||
|
headerTitle: "ASN",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "purpose",
|
||||||
|
accessorKey: "purpose",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs border border-border rounded px-2 py-0.5">{row.original.purpose}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Назначение",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "filter",
|
||||||
|
accessorKey: "filter",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Фильтр" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||||
|
<FilterIcon className="size-3 text-muted-foreground" />
|
||||||
|
{row.original.filter}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Фильтр",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "updated",
|
||||||
|
accessorKey: "updated",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Обновлён" />,
|
||||||
|
cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.original.updated}</span>,
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Обновлён",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "enabled",
|
||||||
|
accessorKey: "enabled",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium ${row.original.enabled ? "text-emerald-600" : "text-muted-foreground"}`}
|
||||||
|
>
|
||||||
|
{row.original.enabled ? "Активен" : "Отключён"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Статус",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: domains,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
...(pagination ? { getPaginationRowModel: getPaginationRowModel() } : {}),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={domains.length}
|
||||||
|
isLoading={isLoading}
|
||||||
|
loadingMode="skeleton"
|
||||||
|
pagination={pagination}
|
||||||
|
emptyMessage={
|
||||||
|
<EmptyState
|
||||||
|
icon={<GlobeIcon className="size-4" />}
|
||||||
|
title="Нет доменов"
|
||||||
|
description="Добавьте домены или импортируйте каталог"
|
||||||
|
className="border-0 py-12"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DomainsDataGrid, type DomainsDataGridProps }
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import type { FilterRule, GreTunnel, Server } from "@/lib/data"
|
||||||
|
import { Flag } from "@/components/flag"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import {
|
||||||
|
AlertCircleIcon,
|
||||||
|
AlertTriangleIcon,
|
||||||
|
CheckCircle2Icon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
|
CircleDashedIcon,
|
||||||
|
PencilIcon,
|
||||||
|
RouteIcon,
|
||||||
|
StarIcon,
|
||||||
|
TrashIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
FilterIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
export type FilterRouterSyncStatus = "synced" | "drift" | "missing"
|
||||||
|
|
||||||
|
export interface RecursiveRouteLite {
|
||||||
|
id: string
|
||||||
|
dstAddress: string
|
||||||
|
gateway: string
|
||||||
|
distance: number
|
||||||
|
routingTable: string
|
||||||
|
comment: string
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouterSyncMarker({
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
status: FilterRouterSyncStatus | null | "skip"
|
||||||
|
}) {
|
||||||
|
if (status === "skip") {
|
||||||
|
return <span className="size-3.5 shrink-0 block" aria-hidden />
|
||||||
|
}
|
||||||
|
const icon =
|
||||||
|
status === "synced"
|
||||||
|
? <CheckCircle2Icon className="size-3.5 text-emerald-600 dark:text-emerald-500 shrink-0" />
|
||||||
|
: status === "drift"
|
||||||
|
? <AlertTriangleIcon className="size-3.5 text-amber-500 shrink-0" />
|
||||||
|
: status === "missing"
|
||||||
|
? <XCircleIcon className="size-3.5 text-destructive shrink-0" />
|
||||||
|
: <CircleDashedIcon className="size-3.5 text-muted-foreground/35 shrink-0" />
|
||||||
|
const title =
|
||||||
|
status === "synced"
|
||||||
|
? "Совпадает с цепочкой bgp-in на MikroTik"
|
||||||
|
: status === "drift"
|
||||||
|
? "В БД и на роутере разное действие (gateway, blackhole или out-interface)"
|
||||||
|
: status === "missing"
|
||||||
|
? "Эта community не найдена в правиле bgp-in на роутере"
|
||||||
|
: "Не проверено — нажмите «Сверить с роутером»"
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger className="inline-flex cursor-default border-0 bg-transparent p-0">
|
||||||
|
{icon}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
|
{title}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function innerIpToGateway(ip: string) {
|
||||||
|
return ip.split("/")[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function gatewayFromRecursiveDst(dstAddress: string) {
|
||||||
|
return innerIpToGateway((dstAddress ?? "").trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickRecursiveRouteByGatewayHop(routes: RecursiveRouteLite[], hopIp: string) {
|
||||||
|
const hop = hopIp.trim()
|
||||||
|
if (!hop) return undefined
|
||||||
|
const candidates = routes.filter(
|
||||||
|
(r) => !r.disabled && gatewayFromRecursiveDst(r.dstAddress) === hop,
|
||||||
|
)
|
||||||
|
if (candidates.length === 0) return undefined
|
||||||
|
return candidates.reduce((a, b) => (a.distance <= b.distance ? a : b))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecursiveGatewayRef(ref: string) {
|
||||||
|
return ref.startsWith("rec:")
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterGatewayCell({
|
||||||
|
rule,
|
||||||
|
tunnelsList,
|
||||||
|
serversList,
|
||||||
|
recursiveRoutes,
|
||||||
|
}: {
|
||||||
|
rule: FilterRule
|
||||||
|
tunnelsList: GreTunnel[]
|
||||||
|
serversList: Server[]
|
||||||
|
recursiveRoutes: RecursiveRouteLite[]
|
||||||
|
}) {
|
||||||
|
const isBlackhole = rule.action === "blackhole"
|
||||||
|
const isRecRef = !isBlackhole && isRecursiveGatewayRef(rule.gatewayTunnelId)
|
||||||
|
const recRowByRef = isRecRef
|
||||||
|
? recursiveRoutes.find((r) => r.id === rule.gatewayTunnelId.slice(4))
|
||||||
|
: undefined
|
||||||
|
const recRowByHop =
|
||||||
|
!isBlackhole && !(rule.gatewayTunnelId ?? "").trim() && rule.gateway.trim()
|
||||||
|
? pickRecursiveRouteByGatewayHop(recursiveRoutes, rule.gateway)
|
||||||
|
: undefined
|
||||||
|
const recRow = recRowByRef ?? recRowByHop
|
||||||
|
const treatAsRecursive = !isBlackhole && (isRecRef || !!recRowByHop)
|
||||||
|
const tunnel =
|
||||||
|
!isBlackhole && !treatAsRecursive
|
||||||
|
? tunnelsList.find((t) => t.id === rule.gatewayTunnelId)
|
||||||
|
: undefined
|
||||||
|
const remoteSrv = tunnel ? serversList.find((s) => s.host === tunnel.remoteAddress) : undefined
|
||||||
|
|
||||||
|
if (isBlackhole) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="size-1.5 rounded-full shrink-0 bg-red-500 animate-pulse" />
|
||||||
|
<span className="font-mono text-xs font-medium text-red-600 dark:text-red-400">type=blackhole</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 flex flex-col gap-0.5">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-1.5 rounded-full shrink-0",
|
||||||
|
treatAsRecursive
|
||||||
|
? "bg-sky-500"
|
||||||
|
: tunnel?.status === "up"
|
||||||
|
? "bg-[var(--status-online)]"
|
||||||
|
: tunnel?.status === "degraded"
|
||||||
|
? "bg-[var(--status-degraded)]"
|
||||||
|
: "bg-[var(--status-offline)]",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{treatAsRecursive ? (
|
||||||
|
<>
|
||||||
|
<RouteIcon className="size-3 text-muted-foreground shrink-0" />
|
||||||
|
<span className="font-mono text-xs font-medium">
|
||||||
|
{recRow ? gatewayFromRecursiveDst(recRow.dstAddress) : rule.gateway}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground border border-border rounded px-1 uppercase tracking-wide">
|
||||||
|
recursive
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{remoteSrv && <Flag code={remoteSrv.country} />}
|
||||||
|
<span className="font-mono text-xs font-medium">{rule.gateway}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{treatAsRecursive ? (
|
||||||
|
recRow ? (
|
||||||
|
<p className="text-[11px] text-muted-foreground truncate pl-3">{recRow.dstAddress}</p>
|
||||||
|
) : isRecRef ? (
|
||||||
|
<p className="text-[11px] text-amber-600 dark:text-amber-400 truncate pl-3">
|
||||||
|
рекурсивный маршрут (нет строки в списке)
|
||||||
|
</p>
|
||||||
|
) : null
|
||||||
|
) : tunnel ? (
|
||||||
|
<p className="text-[11px] text-muted-foreground truncate pl-3">{tunnel.name}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterRowActions({
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
onEdit: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}) {
|
||||||
|
const [confirmDel, setConfirmDel] = useState(false)
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0.5 justify-end opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="size-7 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={onEdit}
|
||||||
|
>
|
||||||
|
<PencilIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"size-7 p-0 transition-colors",
|
||||||
|
confirmDel
|
||||||
|
? "text-destructive bg-destructive/10 hover:bg-destructive/20"
|
||||||
|
: "text-muted-foreground hover:text-destructive",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!confirmDel) setConfirmDel(true)
|
||||||
|
else onDelete()
|
||||||
|
}}
|
||||||
|
onBlur={() => setConfirmDel(false)}
|
||||||
|
>
|
||||||
|
{confirmDel ? <AlertCircleIcon className="size-3.5" /> : <TrashIcon className="size-3.5" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FiltersDataGridProps {
|
||||||
|
rules: FilterRule[]
|
||||||
|
tunnelsList: GreTunnel[]
|
||||||
|
serversList: Server[]
|
||||||
|
communityNameMap: Record<string, string>
|
||||||
|
recursiveRoutes: RecursiveRouteLite[]
|
||||||
|
routerSyncByCommunity?: Record<string, FilterRouterSyncStatus> | null
|
||||||
|
isLive?: boolean
|
||||||
|
enableSorting?: boolean
|
||||||
|
onEdit: (rule: FilterRule) => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
onMoveUp: (id: string) => void
|
||||||
|
onMoveDown: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function FiltersDataGrid({
|
||||||
|
rules,
|
||||||
|
tunnelsList,
|
||||||
|
serversList,
|
||||||
|
communityNameMap,
|
||||||
|
recursiveRoutes,
|
||||||
|
routerSyncByCommunity,
|
||||||
|
isLive,
|
||||||
|
enableSorting = false,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onMoveUp,
|
||||||
|
onMoveDown,
|
||||||
|
}: FiltersDataGridProps) {
|
||||||
|
const columns = useMemo<ColumnDef<FilterRule>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "priority",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">#</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row, table }) => {
|
||||||
|
const isLast = row.index === table.getRowModel().rows.length - 1
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center text-[11px] font-mono text-muted-foreground/40">
|
||||||
|
{isLast ? (
|
||||||
|
<StarIcon className="size-3 text-amber-400 fill-amber-400" aria-label="Наивысший приоритет" />
|
||||||
|
) : (
|
||||||
|
<span>{row.index + 1}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
size: 28,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "reorder",
|
||||||
|
header: () => <span className="sr-only">Порядок</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row, table }) => {
|
||||||
|
const isLast = row.index === table.getRowModel().rows.length - 1
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-px opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onMoveUp(row.original.id)}
|
||||||
|
disabled={row.index === 0}
|
||||||
|
className="text-muted-foreground/50 hover:text-foreground disabled:opacity-20"
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onMoveDown(row.original.id)}
|
||||||
|
disabled={isLast}
|
||||||
|
className="text-muted-foreground/50 hover:text-foreground disabled:opacity-20"
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
size: 28,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "routerSync",
|
||||||
|
header: () => (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger className="cursor-help font-mono text-xs text-muted-foreground border-0 bg-transparent p-0">
|
||||||
|
MT
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
|
Совпадение с MikroTik (bgp-in)
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<RouterSyncMarker
|
||||||
|
status={
|
||||||
|
!isLive
|
||||||
|
? "skip"
|
||||||
|
: !routerSyncByCommunity
|
||||||
|
? null
|
||||||
|
: routerSyncByCommunity[row.original.community.trim()] ?? null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
size: 32,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "community",
|
||||||
|
accessorKey: "community",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Community" />,
|
||||||
|
enableSorting,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const rule = row.original
|
||||||
|
const isBlackhole = rule.action === "blackhole"
|
||||||
|
const communityName = communityNameMap[rule.community] ?? rule.communityName
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border font-mono",
|
||||||
|
isBlackhole
|
||||||
|
? "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/25"
|
||||||
|
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{rule.community}
|
||||||
|
</span>
|
||||||
|
{isBlackhole && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold border bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20 uppercase">
|
||||||
|
⊘ blackhole
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{communityName && (
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">{communityName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerTitle: "Community", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gateway",
|
||||||
|
accessorKey: "gateway",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Gateway" />,
|
||||||
|
enableSorting,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<FilterGatewayCell
|
||||||
|
rule={row.original}
|
||||||
|
tunnelsList={tunnelsList}
|
||||||
|
serversList={serversList}
|
||||||
|
recursiveRoutes={recursiveRoutes}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: "Gateway", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "description",
|
||||||
|
accessorKey: "description",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Описание" />,
|
||||||
|
enableSorting,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{row.original.description || "—"}</p>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: "Описание", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<FilterRowActions
|
||||||
|
onEdit={() => onEdit(row.original)}
|
||||||
|
onDelete={() => onDelete(row.original.id)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
size: 64,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
communityNameMap,
|
||||||
|
enableSorting,
|
||||||
|
isLive,
|
||||||
|
onDelete,
|
||||||
|
onEdit,
|
||||||
|
onMoveDown,
|
||||||
|
onMoveUp,
|
||||||
|
recursiveRoutes,
|
||||||
|
routerSyncByCommunity,
|
||||||
|
serversList,
|
||||||
|
tunnelsList,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: rules,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rules.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<FilterIcon className="size-4" />}
|
||||||
|
title="Ничего не найдено"
|
||||||
|
className="border-0 py-16"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataGridShell table={table} recordCount={rules.length} />
|
||||||
|
<div className="px-5 py-2 text-[11px] text-muted-foreground/40 flex items-center gap-1.5 border-t">
|
||||||
|
<StarIcon className="size-3 text-amber-400 fill-amber-400 shrink-0" />
|
||||||
|
Последнее правило имеет наивысший приоритет в RouterOS
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { FiltersDataGrid, RouterSyncMarker, type FiltersDataGridProps }
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import type { FirewallRule } from "@/lib/data"
|
||||||
|
import { FormToggle } from "@/components/form-kit"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import {
|
||||||
|
CopyIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
PencilIcon,
|
||||||
|
PowerIcon,
|
||||||
|
ShieldOffIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
const ACTION_STYLES: Record<string, string> = {
|
||||||
|
accept: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
||||||
|
drop: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||||
|
reject: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||||
|
masquerade: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
|
||||||
|
"mark-routing": "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||||||
|
"mark-conn": "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||||||
|
"fasttrack-connection": "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
|
||||||
|
"dst-nat": "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||||
|
"src-nat": "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||||
|
"add-src-to-address-list": "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20",
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHAIN_STYLES: Record<string, string> = {
|
||||||
|
forward: "bg-foreground/5 text-foreground/70",
|
||||||
|
input: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
|
||||||
|
output: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||||
|
srcnat: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||||
|
dstnat: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||||
|
prerouting: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
||||||
|
postrouting: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
||||||
|
"ip6-input": "bg-violet-500/10 text-violet-600 dark:text-violet-400",
|
||||||
|
"ip6-forward": "bg-foreground/5 text-foreground/70",
|
||||||
|
"ip6-output": "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtHits(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}М`
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}к`
|
||||||
|
return String(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionBadge({ action }: { action: string }) {
|
||||||
|
const cls = ACTION_STYLES[action] ?? "bg-muted text-muted-foreground border-border"
|
||||||
|
return (
|
||||||
|
<span className={cn("text-[11px] font-mono font-medium px-2 py-0.5 rounded border whitespace-nowrap", cls)}>
|
||||||
|
{action}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChainBadge({ chain }: { chain: string }) {
|
||||||
|
const cls = CHAIN_STYLES[chain] ?? "bg-muted text-muted-foreground"
|
||||||
|
return (
|
||||||
|
<span className={cn("text-[11px] font-mono px-2 py-0.5 rounded", cls)}>
|
||||||
|
{chain}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FirewallRulesDataGridProps {
|
||||||
|
rules: FirewallRule[]
|
||||||
|
onToggle: (id: string) => void
|
||||||
|
onEdit: (rule: FirewallRule) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGridProps) {
|
||||||
|
const indexedRules = useMemo(
|
||||||
|
() => rules.map((rule, index) => ({ ...rule, _index: index + 1 })),
|
||||||
|
[rules],
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<FirewallRule & { _index: number }>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "index",
|
||||||
|
accessorKey: "_index",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">#</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className="font-mono text-xs text-muted-foreground"
|
||||||
|
data-rule-disabled={!row.original.enabled ? true : undefined}
|
||||||
|
>
|
||||||
|
{row.original._index}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
size: 48,
|
||||||
|
meta: {
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chain",
|
||||||
|
accessorKey: "chain",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Цепочка" />,
|
||||||
|
cell: ({ row }) => <ChainBadge chain={row.original.chain} />,
|
||||||
|
meta: { headerTitle: "Цепочка", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "action",
|
||||||
|
accessorKey: "action",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Действие" />,
|
||||||
|
cell: ({ row }) => <ActionBadge action={row.original.action} />,
|
||||||
|
meta: { headerTitle: "Действие", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "src",
|
||||||
|
accessorKey: "src",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Источник" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground max-w-[140px] truncate block">
|
||||||
|
{row.original.src || "any"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: "Источник", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dst",
|
||||||
|
accessorKey: "dst",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground max-w-[140px] truncate block">
|
||||||
|
{row.original.dst || "any"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: "Назначение", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "proto",
|
||||||
|
accessorKey: "proto",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Протокол" />,
|
||||||
|
cell: ({ row }) => <span className="text-xs font-mono">{row.original.proto}</span>,
|
||||||
|
meta: { headerTitle: "Протокол", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "port",
|
||||||
|
accessorKey: "port",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Порт" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{row.original.port || "—"}</span>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: "Порт", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "iface",
|
||||||
|
accessorKey: "iface",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{row.original.iface || "—"}</span>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: "Интерфейс", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hits",
|
||||||
|
accessorKey: "hits",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="Пакетов" className="ml-auto" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const hits = row.original.hits
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-mono tabular-nums text-right block",
|
||||||
|
hits > 1_000_000
|
||||||
|
? "text-emerald-600 dark:text-emerald-400 font-semibold"
|
||||||
|
: hits > 10_000
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{fmtHits(hits)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Пакетов",
|
||||||
|
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "enabled",
|
||||||
|
accessorKey: "enabled",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Вкл</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<FormToggle checked={row.original.enabled} onChange={() => onToggle(row.original.id)} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||||
|
"opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100",
|
||||||
|
"data-popup-open:opacity-100",
|
||||||
|
)}
|
||||||
|
aria-label={`Действия: ${r.chain} ${r.action}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent side="bottom" align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onEdit(r)}>
|
||||||
|
<PencilIcon className="size-4" />
|
||||||
|
Редактировать
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<CopyIcon className="size-4" />
|
||||||
|
Дублировать
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => onToggle(r.id)}>
|
||||||
|
<PowerIcon className="size-4" />
|
||||||
|
{r.enabled ? "Отключить" : "Включить"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem variant="destructive">
|
||||||
|
<Trash2Icon className="size-4" />
|
||||||
|
Удалить правило
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
size: 56,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[onEdit, onToggle],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: indexedRules,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rules.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<ShieldOffIcon className="size-4" />}
|
||||||
|
title="Правила не найдены"
|
||||||
|
description="Попробуйте изменить фильтр или добавьте новое правило"
|
||||||
|
className="border-0 py-16"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={rules.length}
|
||||||
|
tableClassNames={{
|
||||||
|
headerRow: "border-b border-border",
|
||||||
|
bodyRow: cn("group/row", "[&:has([data-rule-disabled=true])]:opacity-40"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { FirewallRulesDataGrid, type FirewallRulesDataGridProps, ActionBadge, ChainBadge }
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { ActionBadge, ChainBadge } from "@/components/data-grids/firewall-rules-data-grid"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { PowerIcon, Trash2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
function ScenarioCell({ enabled, children }: { enabled: boolean; children: React.ReactNode }) {
|
||||||
|
return <div className={cn(!enabled && "opacity-40")}>{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScenarioRuleRow {
|
||||||
|
id: string
|
||||||
|
chain: string
|
||||||
|
action: string
|
||||||
|
proto: string
|
||||||
|
src: string
|
||||||
|
dst: string
|
||||||
|
port: string
|
||||||
|
iface: string
|
||||||
|
comment: string
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FirewallScenarioRulesDataGridProps {
|
||||||
|
rules: ScenarioRuleRow[]
|
||||||
|
onToggleEnabled: (id: string) => void
|
||||||
|
onMoveUp: (id: string) => void
|
||||||
|
onMoveDown: (id: string) => void
|
||||||
|
onRemove: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function FirewallScenarioRulesDataGrid({
|
||||||
|
rules,
|
||||||
|
onToggleEnabled,
|
||||||
|
onMoveUp,
|
||||||
|
onMoveDown,
|
||||||
|
onRemove,
|
||||||
|
}: FirewallScenarioRulesDataGridProps) {
|
||||||
|
const indexedRules = useMemo(
|
||||||
|
() => rules.map((rule, index) => ({ ...rule, _index: index + 1 })),
|
||||||
|
[rules],
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<ScenarioRuleRow & { _index: number }>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "index",
|
||||||
|
accessorKey: "_index",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">#</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<ScenarioCell enabled={row.original.enabled}>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground tabular-nums">{row.original._index}</span>
|
||||||
|
</ScenarioCell>
|
||||||
|
),
|
||||||
|
size: 40,
|
||||||
|
meta: {
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chain",
|
||||||
|
accessorKey: "chain",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Цепочка</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<ScenarioCell enabled={row.original.enabled}>
|
||||||
|
<ChainBadge chain={row.original.chain} />
|
||||||
|
</ScenarioCell>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "action",
|
||||||
|
accessorKey: "action",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Действие</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => <ActionBadge action={row.original.action} />,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "src",
|
||||||
|
accessorKey: "src",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Src</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground max-w-[90px] truncate block">
|
||||||
|
{row.original.src || "any"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dst",
|
||||||
|
accessorKey: "dst",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Dst</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground max-w-[90px] truncate block">
|
||||||
|
{row.original.dst || "any"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "port",
|
||||||
|
accessorKey: "port",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Порт</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{row.original.port || "—"}</span>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "iface",
|
||||||
|
accessorKey: "iface",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Iface</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{row.original.iface || "—"}</span>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "comment",
|
||||||
|
accessorKey: "comment",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Комментарий</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs text-muted-foreground/70 max-w-[110px] truncate block">
|
||||||
|
{row.original.comment || "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => null,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const index = row.original._index - 1
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0.5 justify-end opacity-0 group-hover/row:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleEnabled(row.original.id)}
|
||||||
|
title={row.original.enabled ? "Отключить" : "Включить"}
|
||||||
|
className="p-0.5 text-muted-foreground/40 hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<PowerIcon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onMoveUp(row.original.id)}
|
||||||
|
disabled={index === 0}
|
||||||
|
className="p-0.5 text-muted-foreground/40 hover:text-foreground disabled:opacity-20 transition-colors"
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onMoveDown(row.original.id)}
|
||||||
|
disabled={index === rules.length - 1}
|
||||||
|
className="p-0.5 text-muted-foreground/40 hover:text-foreground disabled:opacity-20 transition-colors"
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(row.original.id)}
|
||||||
|
className="p-0.5 ml-0.5 text-muted-foreground/40 hover:text-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[onMoveDown, onMoveUp, onRemove, onToggleEnabled, rules.length],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: indexedRules,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={rules.length}
|
||||||
|
tableClassNames={{
|
||||||
|
bodyRow: cn("group/row text-xs", "hover:bg-muted/20 transition-colors"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { FirewallScenarioRulesDataGrid, type FirewallScenarioRulesDataGridProps }
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import type { GrePool } from "@/lib/data"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { LayersIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
interface GrePoolsDataGridProps {
|
||||||
|
pools: GrePool[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function GrePoolsDataGrid({ pools }: GrePoolsDataGridProps) {
|
||||||
|
const columns = useMemo<ColumnDef<GrePool>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="Имя пула" className="ml-1" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-[13px] font-medium">{row.original.name}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Имя пула",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cidr",
|
||||||
|
accessorKey: "cidr",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Диапазон CIDR" />,
|
||||||
|
cell: ({ row }) => <span className="font-mono text-xs">{row.original.cidr}</span>,
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Диапазон CIDR",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "allocated",
|
||||||
|
accessorKey: "allocated",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="Назначено /30" className="ml-auto" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="tabular-nums text-right block">{row.original.allocated}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Назначено /30",
|
||||||
|
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "available",
|
||||||
|
header: () => (
|
||||||
|
<span className="text-xs font-medium text-muted-foreground block text-right">
|
||||||
|
Доступно /30
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const p = row.original
|
||||||
|
return (
|
||||||
|
<span className="tabular-nums text-muted-foreground text-right block">
|
||||||
|
{p.total - p.allocated}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "usage",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Использование</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const pool = row.original
|
||||||
|
const pct = pool.total > 0 ? Math.round((pool.allocated / pool.total) * 100) : 0
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 min-w-[140px]">
|
||||||
|
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn("h-full rounded-full", pct > 80 ? "bg-amber-500" : "bg-emerald-500")}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums w-8 text-right">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "comment",
|
||||||
|
accessorKey: "comment",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground text-xs">{row.original.comment}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Назначение",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: () => (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||||
|
"opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100",
|
||||||
|
"data-popup-open:opacity-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent side="bottom" align="end">
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<PencilIcon className="size-4" /> Редактировать
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem variant="destructive">
|
||||||
|
<Trash2Icon className="size-4" /> Удалить пул
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
size: 56,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: pools,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (pools.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<LayersIcon className="size-4" />}
|
||||||
|
title="Нет IP-пулов"
|
||||||
|
className="border-0 py-16"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={pools.length}
|
||||||
|
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row" }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { GrePoolsDataGrid, type GrePoolsDataGridProps }
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import type {
|
||||||
|
GrePool,
|
||||||
|
GreTunnel,
|
||||||
|
GreStatus,
|
||||||
|
IpsecEncAlg,
|
||||||
|
IpsecAuthAlg,
|
||||||
|
IpsecDhGroup,
|
||||||
|
IkeVersion,
|
||||||
|
Server,
|
||||||
|
} from "@/lib/data"
|
||||||
|
import { Flag } from "@/components/flag"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import {
|
||||||
|
CodeXmlIcon,
|
||||||
|
LockIcon,
|
||||||
|
LockOpenIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
NetworkIcon,
|
||||||
|
PencilIcon,
|
||||||
|
PowerIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
const ENC_LABELS: Record<IpsecEncAlg, string> = {
|
||||||
|
"aes-128": "AES-128",
|
||||||
|
"aes-192": "AES-192",
|
||||||
|
"aes-256": "AES-256",
|
||||||
|
}
|
||||||
|
const AUTH_LABELS: Record<IpsecAuthAlg, string> = {
|
||||||
|
sha1: "SHA-1",
|
||||||
|
sha256: "SHA-256",
|
||||||
|
sha512: "SHA-512",
|
||||||
|
}
|
||||||
|
const DH_LABELS: Record<IpsecDhGroup, string> = {
|
||||||
|
modp1024: "DH-2 (1024)",
|
||||||
|
modp2048: "DH-14 (2048)",
|
||||||
|
modp4096: "DH-16 (4096)",
|
||||||
|
ecp256: "ECP-256",
|
||||||
|
ecp384: "ECP-384",
|
||||||
|
ecp521: "ECP-521",
|
||||||
|
}
|
||||||
|
const IKE_LABELS: Record<IkeVersion, string> = { ikev1: "IKEv1", ikev2: "IKEv2" }
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<GreStatus, { label: string; dot: string }> = {
|
||||||
|
up: { label: "Up", dot: "bg-emerald-500" },
|
||||||
|
degraded: { label: "Degraded", dot: "bg-amber-500" },
|
||||||
|
down: { label: "Down", dot: "bg-red-500" },
|
||||||
|
}
|
||||||
|
|
||||||
|
function TunnelStatus({ status }: { status: GreStatus }) {
|
||||||
|
const s = STATUS_MAP[status]
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm">
|
||||||
|
<span className={cn("size-1.5 rounded-full", s.dot)} />
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function IpsecBadge({ secured }: { secured: boolean }) {
|
||||||
|
return secured ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs font-medium border rounded px-2 py-0.5 bg-emerald-500/10 text-emerald-400 border-emerald-500/20">
|
||||||
|
<LockIcon className="size-3" /> IPsec
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs font-medium border rounded px-2 py-0.5 bg-muted text-muted-foreground border-border">
|
||||||
|
<LockOpenIcon className="size-3" /> Открытый
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GreTunnelsDataGridProps {
|
||||||
|
tunnels: GreTunnel[]
|
||||||
|
servers: Server[]
|
||||||
|
pools: GrePool[]
|
||||||
|
onCodePreview: (tunnel: GreTunnel) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function GreTunnelsDataGrid({
|
||||||
|
tunnels,
|
||||||
|
servers,
|
||||||
|
pools,
|
||||||
|
onCodePreview,
|
||||||
|
}: GreTunnelsDataGridProps) {
|
||||||
|
const serverMap = useMemo(() => new Map(servers.map((s) => [s.id, s])), [servers])
|
||||||
|
const poolMap = useMemo(() => new Map(pools.map((p) => [p.id, p])), [pools])
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<GreTunnel>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="Интерфейс / Сервер" className="ml-1" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const t = row.original
|
||||||
|
const srv = serverMap.get(t.serverId)
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium font-mono text-[13px]">{t.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
|
||||||
|
{srv && <Flag code={srv.country} />}
|
||||||
|
{srv?.name ?? t.serverId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Интерфейс / Сервер",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "endpoints",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Эндпоинты</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const t = row.original
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-xs">
|
||||||
|
{t.localAddress === "0.0.0.0" ? (
|
||||||
|
<span className="text-muted-foreground">авто</span>
|
||||||
|
) : (
|
||||||
|
t.localAddress
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-xs text-muted-foreground">→ {t.remoteAddress}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "innerIp",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Внутренний IP</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const t = row.original
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-xs">{t.localInnerIp}</p>
|
||||||
|
<p className="font-mono text-xs text-muted-foreground">{t.remoteInnerIp}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pool",
|
||||||
|
accessorKey: "poolId",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Пул" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
{poolMap.get(row.original.poolId)?.name ?? "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: "Пул", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ipsec",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">IPsec</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => <IpsecBadge secured={!!row.original.ipsec} />,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "encryption",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Шифрование</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const t = row.original
|
||||||
|
if (!t.ipsec) return <span className="text-xs text-muted-foreground">—</span>
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-xs font-mono">
|
||||||
|
{ENC_LABELS[t.ipsec.encAlg]} / {AUTH_LABELS[t.ipsec.authAlg]}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
{DH_LABELS[t.ipsec.dhGroup].split(" ")[0]} · {IKE_LABELS[t.ipsec.ikeVersion]}
|
||||||
|
{t.ipsec.pfs && " · PFS"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mtu",
|
||||||
|
accessorKey: "mtu",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="MTU" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs text-center block">{row.original.mtu}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "MTU",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "keepalive",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Keepalive</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const t = row.original
|
||||||
|
return (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
{t.keepaliveInterval === 0 ? "откл." : `${t.keepaliveInterval}с / ${t.keepaliveRetries}`}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
accessorKey: "status",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||||
|
cell: ({ row }) => <TunnelStatus status={row.original.status} />,
|
||||||
|
meta: { headerTitle: "Статус", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const t = row.original
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"size-7 opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100",
|
||||||
|
)}
|
||||||
|
title="Предпросмотр кода RouterOS"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onCodePreview(t)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CodeXmlIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||||
|
"opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100",
|
||||||
|
"data-popup-open:opacity-100",
|
||||||
|
)}
|
||||||
|
aria-label={`Действия: ${t.name}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent side="bottom" align="end">
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuLabel>{t.name}</DropdownMenuLabel>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => onCodePreview(t)}>
|
||||||
|
<CodeXmlIcon className="size-4" /> Просмотр кода
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<PencilIcon className="size-4" /> Редактировать
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<PowerIcon className="size-4" />
|
||||||
|
{t.enabled ? "Выключить" : "Включить"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem variant="destructive">
|
||||||
|
<Trash2Icon className="size-4" /> Удалить туннель
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
size: 88,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[onCodePreview, poolMap, serverMap],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: tunnels,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getRowId: (row, index) => `${row.id}:${row.serverId}:${row.name}:${index}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (tunnels.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<NetworkIcon className="size-4" />}
|
||||||
|
title="Нет GRE-туннелей"
|
||||||
|
description="Измените фильтр или добавьте туннель"
|
||||||
|
className="border-0 py-16"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={tunnels.length}
|
||||||
|
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row" }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { GreTunnelsDataGrid, type GreTunnelsDataGridProps }
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import type { IpRange } from "@/lib/data"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { FilterIcon, NetworkIcon } from "lucide-react"
|
||||||
|
|
||||||
|
interface IpRangesDataGridProps {
|
||||||
|
ipRanges: IpRange[]
|
||||||
|
isLoading?: boolean
|
||||||
|
pagination?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function IpRangesDataGrid({ ipRanges, isLoading, pagination = false }: IpRangesDataGridProps) {
|
||||||
|
const columns = useMemo<ColumnDef<IpRange>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "cidr",
|
||||||
|
accessorKey: "cidr",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="CIDR" className="ml-1" />,
|
||||||
|
cell: ({ row }) => <span className="font-mono font-medium">{row.original.cidr}</span>,
|
||||||
|
meta: {
|
||||||
|
headerTitle: "CIDR",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "asn",
|
||||||
|
accessorKey: "asn",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="ASN" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{row.original.asn}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "ASN",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "country",
|
||||||
|
accessorKey: "country",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Страна" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs border border-border rounded px-2 py-0.5">{row.original.country}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Страна",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "purpose",
|
||||||
|
accessorKey: "purpose",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs border border-border rounded px-2 py-0.5">{row.original.purpose}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Назначение",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "filter",
|
||||||
|
accessorKey: "filter",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Фильтр" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||||
|
<FilterIcon className="size-3 text-muted-foreground" />
|
||||||
|
{row.original.filter}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Фильтр",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "updated",
|
||||||
|
accessorKey: "updated",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Обновлён" />,
|
||||||
|
cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.original.updated}</span>,
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Обновлён",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "enabled",
|
||||||
|
accessorKey: "enabled",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium ${row.original.enabled ? "text-emerald-600" : "text-muted-foreground"}`}
|
||||||
|
>
|
||||||
|
{row.original.enabled ? "Активен" : "Отключён"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Статус",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: ipRanges,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
...(pagination ? { getPaginationRowModel: getPaginationRowModel() } : {}),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={ipRanges.length}
|
||||||
|
isLoading={isLoading}
|
||||||
|
loadingMode="skeleton"
|
||||||
|
pagination={pagination}
|
||||||
|
emptyMessage={
|
||||||
|
<EmptyState
|
||||||
|
icon={<NetworkIcon className="size-4" />}
|
||||||
|
title="Нет IP-диапазонов"
|
||||||
|
description="Добавьте CIDR-блоки или импортируйте каталог"
|
||||||
|
className="border-0 py-12"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { IpRangesDataGrid, type IpRangesDataGridProps }
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { ActivityIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export interface BfdSessionRow {
|
||||||
|
id: string
|
||||||
|
serverId: string
|
||||||
|
serverLabel: string
|
||||||
|
localAddr: string
|
||||||
|
remoteAddr: string
|
||||||
|
state: "Up" | "Down" | "Init" | "AdminDown"
|
||||||
|
interval: number
|
||||||
|
multiplier: number
|
||||||
|
iface: string
|
||||||
|
uptime: string | null
|
||||||
|
multihop: boolean
|
||||||
|
rxInterval: number
|
||||||
|
holdTime: number
|
||||||
|
packetsRx: number
|
||||||
|
packetsTx: number
|
||||||
|
stateChanges: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateClass(state: BfdSessionRow["state"]) {
|
||||||
|
if (state === "Up")
|
||||||
|
return "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/25"
|
||||||
|
if (state === "Init")
|
||||||
|
return "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/25"
|
||||||
|
return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border",
|
||||||
|
color ?? "bg-muted text-muted-foreground border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtMs(ms: number) {
|
||||||
|
if (!ms) return "—"
|
||||||
|
if (ms < 1000) return `${ms}ms`
|
||||||
|
return `${(ms / 1000).toFixed(1)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OspfBfdDataGridProps {
|
||||||
|
sessions: BfdSessionRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function OspfBfdDataGrid({ sessions }: OspfBfdDataGridProps) {
|
||||||
|
const columns = useMemo<ColumnDef<BfdSessionRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "serverLabel",
|
||||||
|
accessorKey: "serverLabel",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="Роутер" className="ml-1" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const b = row.original
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="font-mono whitespace-nowrap">{b.serverLabel}</span>
|
||||||
|
{b.multihop && (
|
||||||
|
<Chip color="bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20">
|
||||||
|
multihop
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Роутер",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "iface",
|
||||||
|
accessorKey: "iface",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-muted-foreground whitespace-nowrap">
|
||||||
|
{row.original.iface || "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Интерфейс",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "localAddr",
|
||||||
|
accessorKey: "localAddr",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Локальный" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono whitespace-nowrap">{row.original.localAddr}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Локальный",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "remoteAddr",
|
||||||
|
accessorKey: "remoteAddr",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Удалённый" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono whitespace-nowrap">{row.original.remoteAddr}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Удалённый",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "state",
|
||||||
|
accessorKey: "state",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Состояние" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium",
|
||||||
|
stateClass(row.original.state),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.original.state}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Состояние",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "uptime",
|
||||||
|
accessorKey: "uptime",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Uptime" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground whitespace-nowrap tabular-nums">
|
||||||
|
{row.original.uptime ?? "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Uptime",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "intervals",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Tx / Rx</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const b = row.original
|
||||||
|
return (
|
||||||
|
<span className="font-mono tabular-nums text-muted-foreground whitespace-nowrap">
|
||||||
|
{fmtMs(b.interval)} / {fmtMs(b.rxInterval)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "holdTime",
|
||||||
|
accessorKey: "holdTime",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Hold" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono tabular-nums text-muted-foreground whitespace-nowrap">
|
||||||
|
{fmtMs(row.original.holdTime)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Hold",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "multiplier",
|
||||||
|
accessorKey: "multiplier",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Mult" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono tabular-nums text-center block">{row.original.multiplier}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Mult",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "packetsRx",
|
||||||
|
accessorKey: "packetsRx",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="Пакеты Rx" className="ml-auto" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono tabular-nums text-right text-muted-foreground block">
|
||||||
|
{row.original.packetsRx.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Пакеты Rx",
|
||||||
|
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "packetsTx",
|
||||||
|
accessorKey: "packetsTx",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="Пакеты Tx" className="ml-auto" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono tabular-nums text-right text-muted-foreground block">
|
||||||
|
{row.original.packetsTx.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Пакеты Tx",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "stateChanges",
|
||||||
|
accessorKey: "stateChanges",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="Переходы" className="ml-auto" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono tabular-nums text-right block">{row.original.stateChanges}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Переходы",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-right"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: sessions,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<ActivityIcon className="size-4" />}
|
||||||
|
title="BFD-сессий не обнаружено"
|
||||||
|
className="border-0 py-10"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={sessions.length}
|
||||||
|
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row hover:bg-muted/30" }}
|
||||||
|
tableLayout={{ dense: true }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { OspfBfdDataGrid, type OspfBfdDataGridProps }
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { NetworkIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export interface OspfNeighborRow {
|
||||||
|
id: string
|
||||||
|
localRouter: string
|
||||||
|
localLabel: string
|
||||||
|
localIface: string
|
||||||
|
remoteRouter: string
|
||||||
|
remoteLabel: string
|
||||||
|
remoteRouterId: string
|
||||||
|
area: string
|
||||||
|
state: "Full" | "2-Way" | "ExStart" | "Down"
|
||||||
|
cost: number
|
||||||
|
uptime: string
|
||||||
|
priority: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateClass(state: OspfNeighborRow["state"]) {
|
||||||
|
if (state === "Full")
|
||||||
|
return "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/25"
|
||||||
|
if (state === "2-Way")
|
||||||
|
return "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/25"
|
||||||
|
return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25"
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OspfNeighborsDataGridProps {
|
||||||
|
neighbors: OspfNeighborRow[]
|
||||||
|
highlightRouterId?: string | null
|
||||||
|
selectedRouterId?: string | null
|
||||||
|
onHighlight?: (routerId: string | null) => void
|
||||||
|
onSelect?: (routerId: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function OspfNeighborsDataGrid({
|
||||||
|
neighbors,
|
||||||
|
highlightRouterId,
|
||||||
|
selectedRouterId,
|
||||||
|
onHighlight,
|
||||||
|
onSelect,
|
||||||
|
}: OspfNeighborsDataGridProps) {
|
||||||
|
const columns = useMemo<ColumnDef<OspfNeighborRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "localLabel",
|
||||||
|
accessorKey: "localLabel",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="Роутер" className="ml-1" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono whitespace-nowrap">{row.original.localLabel}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Роутер",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "localIface",
|
||||||
|
accessorKey: "localIface",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-muted-foreground whitespace-nowrap">
|
||||||
|
{row.original.localIface}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Интерфейс",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "remote",
|
||||||
|
header: () => (
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Сосед (Router ID)</span>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const n = row.original
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-mono">
|
||||||
|
{n.remoteLabel !== n.remoteRouterId ? n.remoteLabel : n.remoteRouterId}
|
||||||
|
</span>
|
||||||
|
{n.remoteLabel !== n.remoteRouterId && (
|
||||||
|
<span className="text-[10px] font-mono text-muted-foreground">{n.remoteRouterId}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "area",
|
||||||
|
accessorKey: "area",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Область" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-muted-foreground">{row.original.area}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Область",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "state",
|
||||||
|
accessorKey: "state",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Состояние" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium",
|
||||||
|
stateClass(row.original.state),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.original.state}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Состояние",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cost",
|
||||||
|
accessorKey: "cost",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Cost" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono tabular-nums text-center block">{row.original.cost}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Cost",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "uptime",
|
||||||
|
accessorKey: "uptime",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Uptime" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground whitespace-nowrap tabular-nums">
|
||||||
|
{row.original.uptime}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Uptime",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "priority",
|
||||||
|
accessorKey: "priority",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Prio" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-center font-mono block">{row.original.priority}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Prio",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-center"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const rowSelection = useMemo(() => {
|
||||||
|
if (!selectedRouterId) return {}
|
||||||
|
const match = neighbors.find(
|
||||||
|
(n) => n.localRouter === selectedRouterId || n.remoteRouter === selectedRouterId,
|
||||||
|
)
|
||||||
|
return match ? { [match.id]: true } : {}
|
||||||
|
}, [neighbors, selectedRouterId])
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: neighbors,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
enableRowSelection: true,
|
||||||
|
state: { rowSelection },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (neighbors.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<NetworkIcon className="size-4" />}
|
||||||
|
title="Нет OSPF-соседей"
|
||||||
|
className="border-0 py-10"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={neighbors.length}
|
||||||
|
onRowClick={(row) => {
|
||||||
|
onSelect?.(selectedRouterId === row.localRouter ? null : row.localRouter)
|
||||||
|
}}
|
||||||
|
tableClassNames={{
|
||||||
|
headerRow: "border-b border-border",
|
||||||
|
bodyRow: cn(
|
||||||
|
"group/row cursor-pointer",
|
||||||
|
"data-[state=selected]:bg-primary/5",
|
||||||
|
highlightRouterId && "[&:hover]:bg-muted/30",
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
tableLayout={{ dense: true }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { OspfNeighborsDataGrid, type OspfNeighborsDataGridProps }
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { RouteIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export interface OspfRouteRow {
|
||||||
|
id: string
|
||||||
|
destination: string
|
||||||
|
type: "O" | "O IA" | "O E1" | "O E2"
|
||||||
|
cost: number
|
||||||
|
nextHop: string
|
||||||
|
via: string
|
||||||
|
serverId: string
|
||||||
|
serverLabel: string
|
||||||
|
area: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeTypeClass(type: OspfRouteRow["type"]) {
|
||||||
|
if (type === "O") return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25"
|
||||||
|
if (type === "O IA") return "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/25"
|
||||||
|
if (type === "O E1") return "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/25"
|
||||||
|
return "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/25"
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OspfRoutesDataGridProps {
|
||||||
|
routes: OspfRouteRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function OspfRoutesDataGrid({ routes }: OspfRoutesDataGridProps) {
|
||||||
|
const columns = useMemo<ColumnDef<OspfRouteRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "destination",
|
||||||
|
accessorKey: "destination",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="Назначение" className="ml-1" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <span className="font-mono">{row.original.destination}</span>,
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Назначение",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "type",
|
||||||
|
accessorKey: "type",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium",
|
||||||
|
routeTypeClass(row.original.type),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.original.type}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: "Тип", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cost",
|
||||||
|
accessorKey: "cost",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Cost" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono tabular-nums text-center block">{row.original.cost}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Cost",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nextHop",
|
||||||
|
accessorKey: "nextHop",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Следующий хоп" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-muted-foreground">{row.original.nextHop}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Следующий хоп",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "via",
|
||||||
|
accessorKey: "via",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-muted-foreground">{row.original.via}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Интерфейс",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "serverLabel",
|
||||||
|
accessorKey: "serverLabel",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Роутер" />,
|
||||||
|
cell: ({ row }) => <span className="font-mono">{row.original.serverLabel}</span>,
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Роутер",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "area",
|
||||||
|
accessorKey: "area",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Область" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-muted-foreground">{row.original.area}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Область",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: routes,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (routes.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<RouteIcon className="size-4" />}
|
||||||
|
title="Нет OSPF-маршрутов"
|
||||||
|
className="border-0 py-10"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={routes.length}
|
||||||
|
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row hover:bg-muted/30" }}
|
||||||
|
tableLayout={{ dense: true }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { OspfRoutesDataGrid, type OspfRoutesDataGridProps, routeTypeClass }
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import type { Server } from "@/lib/data"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { FormToggle } from "@/components/form-kit"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { ClockIcon, Trash2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
export type SchedType = "ping" | "bandwidth" | "both"
|
||||||
|
|
||||||
|
export interface SchedRule {
|
||||||
|
id: string
|
||||||
|
srcId: string
|
||||||
|
tunnelId: string
|
||||||
|
type: SchedType
|
||||||
|
intervalMin: number
|
||||||
|
enabled: boolean
|
||||||
|
lastRun: string | null
|
||||||
|
nextRunMin: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<SchedType, string> = {
|
||||||
|
ping: "Ping",
|
||||||
|
bandwidth: "BW-тест",
|
||||||
|
both: "Ping + BW",
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProbesScheduleDataGridProps {
|
||||||
|
rules: SchedRule[]
|
||||||
|
serverOptions: Server[]
|
||||||
|
tunnelName: (srcId: string, tunnelId: string) => string | undefined
|
||||||
|
onToggleEnabled: (id: string, enabled: boolean) => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProbesScheduleDataGrid({
|
||||||
|
rules,
|
||||||
|
serverOptions,
|
||||||
|
tunnelName,
|
||||||
|
onToggleEnabled,
|
||||||
|
onDelete,
|
||||||
|
}: ProbesScheduleDataGridProps) {
|
||||||
|
const serverMap = useMemo(
|
||||||
|
() => new Map(serverOptions.map((s) => [s.id, s])),
|
||||||
|
[serverOptions],
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<SchedRule>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "enabled",
|
||||||
|
accessorKey: "enabled",
|
||||||
|
header: () => <span className="sr-only">Вкл</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<FormToggle
|
||||||
|
checked={row.original.enabled}
|
||||||
|
onChange={(v) => onToggleEnabled(row.original.id, v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
size: 48,
|
||||||
|
meta: {
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tunnel",
|
||||||
|
accessorKey: "tunnelId",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Туннель" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<code className="font-mono text-xs truncate block">
|
||||||
|
{tunnelName(row.original.srcId, row.original.tunnelId) ?? row.original.tunnelId}
|
||||||
|
</code>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Туннель",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "server",
|
||||||
|
accessorKey: "srcId",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs text-muted-foreground truncate block">
|
||||||
|
{serverMap.get(row.original.srcId)?.name ?? row.original.srcId}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Сервер",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "type",
|
||||||
|
accessorKey: "type",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] px-1.5 py-0.5 rounded border font-medium w-fit",
|
||||||
|
row.original.type === "ping"
|
||||||
|
? "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20"
|
||||||
|
: row.original.type === "bandwidth"
|
||||||
|
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||||
|
: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{TYPE_LABEL[row.original.type]}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Тип",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "intervalMin",
|
||||||
|
accessorKey: "intervalMin",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Интервал" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs text-muted-foreground">каждые {row.original.intervalMin} мин</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Интервал",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lastRun",
|
||||||
|
header: () => (
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Последний / следующий</span>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const rule = row.original
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-muted-foreground flex items-center gap-2 min-w-0">
|
||||||
|
{rule.lastRun && <span className="truncate">{rule.lastRun}</span>}
|
||||||
|
{rule.nextRunMin != null && rule.enabled && (
|
||||||
|
<span className="text-sky-600 dark:text-sky-400 shrink-0">
|
||||||
|
· через {rule.nextRunMin} мин
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Последний / следующий",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(row.original.id)}
|
||||||
|
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-red-500 hover:bg-red-500/10 transition-colors opacity-0 group-hover/row:opacity-100 focus-visible:opacity-100"
|
||||||
|
aria-label="Удалить правило"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
size: 48,
|
||||||
|
meta: {
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[onDelete, onToggleEnabled, serverMap, tunnelName],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: rules,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rules.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<ClockIcon className="size-4" />}
|
||||||
|
title="Нет правил расписания"
|
||||||
|
description="Добавьте правило ping или bandwidth-теста"
|
||||||
|
className="border-0 py-10"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DataGridShell table={table} recordCount={rules.length} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ProbesScheduleDataGrid, type ProbesScheduleDataGridProps }
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { ClockIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export interface SpeedProbeApiRow {
|
||||||
|
id: string
|
||||||
|
srcServerId: string
|
||||||
|
dstServerId: string
|
||||||
|
srcInterface: string
|
||||||
|
dstInterface: string
|
||||||
|
protocol: string
|
||||||
|
direction: string
|
||||||
|
durationSec: string
|
||||||
|
enabled: boolean
|
||||||
|
lastRunAt: string | null
|
||||||
|
lastTxAvgMbps: number | null
|
||||||
|
lastRxAvgMbps: number | null
|
||||||
|
lastStatus: string | null
|
||||||
|
lastError: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProbesSpeedProbesDataGridProps {
|
||||||
|
rows: SpeedProbeApiRow[]
|
||||||
|
serverName: (id: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProbesSpeedProbesDataGrid({ rows, serverName }: ProbesSpeedProbesDataGridProps) {
|
||||||
|
const columns = useMemo<ColumnDef<SpeedProbeApiRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "src",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Источник" className="ml-1" />,
|
||||||
|
accessorFn: (row) => row.srcServerId,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original
|
||||||
|
return (
|
||||||
|
<span className="truncate font-mono text-xs block">
|
||||||
|
{serverName(r.srcServerId)}
|
||||||
|
{r.srcInterface ? ` · ${r.srcInterface}` : ""}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Источник",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dst",
|
||||||
|
accessorFn: (row) => row.dstServerId,
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original
|
||||||
|
return (
|
||||||
|
<span className="truncate font-mono text-xs block">
|
||||||
|
{serverName(r.dstServerId)}
|
||||||
|
{r.dstInterface ? ` · ${r.dstInterface}` : ""}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Назначение",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "protocol",
|
||||||
|
accessorKey: "protocol",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Протокол" />,
|
||||||
|
cell: ({ row }) => <span className="text-xs">{row.original.protocol.toUpperCase()}</span>,
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Протокол",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "durationSec",
|
||||||
|
accessorKey: "durationSec",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Сек" />,
|
||||||
|
cell: ({ row }) => <span className="font-mono text-xs">{row.original.durationSec}s</span>,
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Сек",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "enabled",
|
||||||
|
accessorKey: "enabled",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Вкл" />,
|
||||||
|
cell: ({ row }) => <span className="text-xs">{row.original.enabled ? "да" : "нет"}</span>,
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Вкл",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lastRunAt",
|
||||||
|
accessorKey: "lastRunAt",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Последний запуск" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-muted-foreground truncate block">
|
||||||
|
{r.lastRunAt ?? "—"}
|
||||||
|
{r.lastStatus === "done" && r.lastTxAvgMbps != null && (
|
||||||
|
<span className="text-emerald-600 dark:text-emerald-400 ml-1">
|
||||||
|
TX≈{r.lastTxAvgMbps.toFixed(1)} RX≈{(r.lastRxAvgMbps ?? 0).toFixed(1)} Mb/s
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{r.lastStatus === "error" && r.lastError && (
|
||||||
|
<span className="text-destructive ml-1 truncate">{r.lastError}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Последний запуск",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[serverName],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: rows,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<ClockIcon className="size-4" />}
|
||||||
|
title="Нет записей speed-test"
|
||||||
|
description="Настраиваются через API PUT /api/uptime/speed-probes"
|
||||||
|
className="border-0 py-10"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={rows.length}
|
||||||
|
tableClassNames={{
|
||||||
|
headerRow: "border-b border-border",
|
||||||
|
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ProbesSpeedProbesDataGrid, type ProbesSpeedProbesDataGridProps }
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getExpandedRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { Flag } from "@/components/flag"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import {
|
||||||
|
AlertCircleIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
PencilIcon,
|
||||||
|
RouteIcon,
|
||||||
|
TrashIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
export interface RecursiveRouteEndpoint {
|
||||||
|
id: string
|
||||||
|
gateway: string
|
||||||
|
distance: number
|
||||||
|
scope: number | null
|
||||||
|
targetScope: number | null
|
||||||
|
checkGateway: string
|
||||||
|
country: string
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecursiveRouteGroup {
|
||||||
|
id?: string
|
||||||
|
key: string
|
||||||
|
dstAddress: string
|
||||||
|
routingTable: string
|
||||||
|
comment: string
|
||||||
|
endpoints: RecursiveRouteEndpoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const INFER_COUNTRIES = [
|
||||||
|
{ code: "RU", keys: ["MSK", "SPB", "RTK", "MTS", "VPSVILLE", "IHOR"] },
|
||||||
|
{ code: "SE", keys: ["SWE", "STO"] },
|
||||||
|
{ code: "FI", keys: ["HEL", "FIN"] },
|
||||||
|
{ code: "DE", keys: ["FRA", "GER", "DE"] },
|
||||||
|
{ code: "NL", keys: ["AMS", "NLD", "NL"] },
|
||||||
|
{ code: "SG", keys: ["SGP", "SIN", "SG"] },
|
||||||
|
{ code: "TR", keys: ["TUR", "TR"] },
|
||||||
|
{ code: "US", keys: ["USA", "US", "NYC", "LAX"] },
|
||||||
|
]
|
||||||
|
|
||||||
|
function inferCountry(name: string): string | null {
|
||||||
|
const upper = name.toUpperCase()
|
||||||
|
for (const c of INFER_COUNTRIES) {
|
||||||
|
if (c.keys.some((k) => upper.includes(k))) return c.code
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteGroupExpandedDetail({ group }: { group: RecursiveRouteGroup }) {
|
||||||
|
const sorted = [...group.endpoints].sort((a, b) => a.distance - b.distance)
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 px-5 py-4 bg-muted/20">
|
||||||
|
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Route: <span className="font-mono text-foreground">{group.dstAddress}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Table:{" "}
|
||||||
|
<span className="font-mono text-foreground">{group.routingTable || "main"}</span>
|
||||||
|
</span>
|
||||||
|
{group.comment && <span className="text-muted-foreground italic">{group.comment}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5">
|
||||||
|
{sorted.map((ep, idx) => (
|
||||||
|
<div key={ep.id} className="rounded-lg border border-border bg-background px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{(ep.country || inferCountry(ep.gateway)) && (
|
||||||
|
<Flag code={ep.country || inferCountry(ep.gateway) || ""} size={16} />
|
||||||
|
)}
|
||||||
|
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
Endpoint {idx + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-mono">distance: {ep.distance}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 font-mono text-sm break-all leading-tight">{ep.gateway}</p>
|
||||||
|
<div className="mt-1.5 text-[11px] text-muted-foreground flex items-center gap-3">
|
||||||
|
<span>scope: {ep.scope ?? "—"}</span>
|
||||||
|
<span>t.scope: {ep.targetScope ?? "—"}</span>
|
||||||
|
<span>check: {ep.checkGateway || "—"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecursiveRoutesDataGridProps {
|
||||||
|
groups: RecursiveRouteGroup[]
|
||||||
|
expandedKey?: string | null
|
||||||
|
onExpandedChange?: (key: string | null) => void
|
||||||
|
onEdit: (group: RecursiveRouteGroup) => void
|
||||||
|
onDelete: (group: RecursiveRouteGroup) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecursiveRoutesDataGrid({
|
||||||
|
groups,
|
||||||
|
expandedKey,
|
||||||
|
onExpandedChange,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: RecursiveRoutesDataGridProps) {
|
||||||
|
const [confirmDeleteKey, setConfirmDeleteKey] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const expanded = useMemo(() => {
|
||||||
|
if (!expandedKey) return {}
|
||||||
|
const g = groups.find((x) => x.key === expandedKey)
|
||||||
|
return g ? { [(g.id ?? g.key)]: true } : {}
|
||||||
|
}, [expandedKey, groups])
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<RecursiveRouteGroup>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "route",
|
||||||
|
accessorKey: "dstAddress",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="Route / Comment" className="ml-1" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const g = row.original
|
||||||
|
const isExpanded = expandedKey === g.key
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 min-w-0">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium truncate">{g.dstAddress}</p>
|
||||||
|
<p className="text-xs font-mono text-muted-foreground">{g.comment || "—"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Route / Comment",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
expandedContent: (group: RecursiveRouteGroup) => (
|
||||||
|
<RouteGroupExpandedDetail group={group} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gateways",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Gateways</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const sorted = [...row.original.endpoints].sort((a, b) => a.distance - b.distance)
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{sorted.map((ep, idx) => {
|
||||||
|
const code = ep.country || inferCountry(ep.gateway)
|
||||||
|
return (
|
||||||
|
<div key={ep.id} className="flex items-center gap-1.5 text-[11px] font-mono">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-1.5 rounded-full shrink-0",
|
||||||
|
idx === 0 ? "bg-emerald-500" : "bg-sky-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{code ? (
|
||||||
|
<Flag code={code} size={14} className="shrink-0" />
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-muted-foreground w-3.5 text-center shrink-0">
|
||||||
|
?
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="font-semibold text-sky-600 dark:text-sky-400 truncate min-w-0">
|
||||||
|
{ep.gateway}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "epCount",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">EP</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs tabular-nums">{row.original.endpoints.length}</span>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "priority",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Priority</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const bestDistance = Math.min(...row.original.endpoints.map((ep) => ep.distance))
|
||||||
|
return <span className="font-mono text-xs">d{bestDistance}</span>
|
||||||
|
},
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "table",
|
||||||
|
accessorKey: "routingTable",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Table" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
{row.original.routingTable || "main"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Table",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const g = row.original
|
||||||
|
const confirmDel = confirmDeleteKey === g.key
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-0.5 justify-end opacity-0 group-hover/row:opacity-100 transition-opacity"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="size-7 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => onEdit(g)}
|
||||||
|
>
|
||||||
|
<PencilIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"size-7 p-0 transition-colors",
|
||||||
|
confirmDel
|
||||||
|
? "text-destructive bg-destructive/10 hover:bg-destructive/20"
|
||||||
|
: "text-muted-foreground hover:text-destructive",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!confirmDel) setConfirmDeleteKey(g.key)
|
||||||
|
else {
|
||||||
|
onDelete(g)
|
||||||
|
setConfirmDeleteKey(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => setConfirmDeleteKey(null)}
|
||||||
|
>
|
||||||
|
{confirmDel ? (
|
||||||
|
<AlertCircleIcon className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<TrashIcon className="size-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
size: 80,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[confirmDeleteKey, expandedKey, onDelete, onEdit],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: groups,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getExpandedRowModel: getExpandedRowModel(),
|
||||||
|
getRowId: (row) => row.id ?? row.key,
|
||||||
|
getRowCanExpand: () => true,
|
||||||
|
state: { expanded },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<RouteIcon className="size-4" />}
|
||||||
|
title="Нет маршрутов"
|
||||||
|
className="border-0 py-10"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={groups.length}
|
||||||
|
onRowClick={(row) => {
|
||||||
|
onExpandedChange?.(expandedKey === row.key ? null : row.key)
|
||||||
|
}}
|
||||||
|
tableClassNames={{
|
||||||
|
headerRow: "border-b border-border",
|
||||||
|
bodyRow: cn("group/row cursor-pointer", expandedKey && "data-[state=selected]:bg-muted/30"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RecursiveRoutesDataGrid, type RecursiveRoutesDataGridProps, inferCountry }
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import type { CommRec } from "@/lib/route-optimizer-data"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { ArrowRightIcon, PinIcon, RefreshCwIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border",
|
||||||
|
color ?? "bg-muted text-muted-foreground border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProbChip({ prob, best }: { prob: number; best?: boolean }) {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
color={
|
||||||
|
best
|
||||||
|
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||||
|
: "bg-muted text-muted-foreground border-border"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{prob}%
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteOptimizerCommRecsDataGridProps {
|
||||||
|
recs: CommRec[]
|
||||||
|
homeId: string
|
||||||
|
pinned: Set<string>
|
||||||
|
applied: Set<string>
|
||||||
|
applying: Set<string>
|
||||||
|
onPin: (key: string) => void
|
||||||
|
onApply: (community: string, homeId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteOptimizerCommRecsDataGrid({
|
||||||
|
recs,
|
||||||
|
homeId,
|
||||||
|
pinned,
|
||||||
|
applied,
|
||||||
|
applying,
|
||||||
|
onPin,
|
||||||
|
onApply,
|
||||||
|
}: RouteOptimizerCommRecsDataGridProps) {
|
||||||
|
const rows = useMemo(
|
||||||
|
() => recs.map((r, idx) => ({ ...r, _rowKey: `${homeId}::${r.community}::${idx}` })),
|
||||||
|
[homeId, recs],
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<(typeof rows)[number]>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "community",
|
||||||
|
accessorKey: "community",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="Community" className="ml-1" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-xs font-medium">{row.original.community}</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground">{row.original.communityName}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Community",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "current",
|
||||||
|
header: () => (
|
||||||
|
<span className="text-[11px] font-medium text-muted-foreground">
|
||||||
|
Текущий (WAN → JH → Exit)
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original
|
||||||
|
if (!r.current) return <span className="text-muted-foreground text-xs">—</span>
|
||||||
|
return (
|
||||||
|
<div className="text-xs flex items-center gap-1 flex-wrap">
|
||||||
|
<span className="font-mono font-medium text-sky-600 dark:text-sky-400">
|
||||||
|
{r.current.wan}
|
||||||
|
</span>
|
||||||
|
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
|
||||||
|
<span>{r.current.jh}</span>
|
||||||
|
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
|
||||||
|
<span className="text-muted-foreground">{r.current.exit}</span>
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground">
|
||||||
|
({r.current.gateway})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "recommended",
|
||||||
|
header: () => <span className="text-[11px] font-medium text-muted-foreground">Рекомендуемый</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original
|
||||||
|
const pinKey = `${homeId}::${r.community}`
|
||||||
|
const isPinned = pinned.has(pinKey)
|
||||||
|
if (!r.recommended) return <span className="text-muted-foreground text-xs">—</span>
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"text-xs flex items-center gap-1 flex-wrap",
|
||||||
|
r.shouldSwitch && !isPinned && "text-amber-600 dark:text-amber-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-mono font-medium">{r.recommended.wan}</span>
|
||||||
|
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
|
||||||
|
<span>{r.recommended.jh}</span>
|
||||||
|
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
|
||||||
|
<span>{r.recommended.exit}</span>
|
||||||
|
{r.shouldSwitch && !isPinned && (
|
||||||
|
<span className="ml-1 text-[10px] font-bold bg-amber-500/10 border border-amber-500/20 px-1.5 py-0.5 rounded">
|
||||||
|
+{(r.recommended.prob ?? 0) - (r.current?.prob ?? 0)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "probability",
|
||||||
|
header: () => (
|
||||||
|
<span className="text-[11px] font-medium text-muted-foreground block text-center">
|
||||||
|
P(тек / рек)
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original
|
||||||
|
const pinKey = `${homeId}::${r.community}`
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<ProbChip prob={r.current?.prob ?? 0} />
|
||||||
|
<span className="text-muted-foreground text-[10px]">/</span>
|
||||||
|
<ProbChip prob={r.recommended?.prob ?? 0} best={r.shouldSwitch && !pinned.has(pinKey)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => (
|
||||||
|
<span className="text-[11px] font-medium text-muted-foreground block text-right">
|
||||||
|
Действие
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original
|
||||||
|
const pinKey = `${homeId}::${r.community}`
|
||||||
|
const isPinned = pinned.has(pinKey)
|
||||||
|
const isApplied = applied.has(pinKey)
|
||||||
|
const isApplying = applying.has(pinKey)
|
||||||
|
const canApply = r.shouldSwitch && !isPinned && !isApplied
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{isPinned && <PinIcon className="size-3 text-sky-500 fill-sky-500" />}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={cn("h-7 text-xs", isPinned && "text-sky-600 dark:text-sky-400 border-sky-500/30")}
|
||||||
|
onClick={() => onPin(pinKey)}
|
||||||
|
>
|
||||||
|
<PinIcon className={cn("size-3", isPinned && "fill-current")} />
|
||||||
|
{isPinned ? "Открепить" : "Закрепить"}
|
||||||
|
</Button>
|
||||||
|
{canApply && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={isApplying}
|
||||||
|
onClick={() => onApply(r.community, homeId)}
|
||||||
|
>
|
||||||
|
{isApplying ? (
|
||||||
|
<RefreshCwIcon className="size-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Применить"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isApplied && (
|
||||||
|
<span className="text-[10px] text-emerald-600 dark:text-emerald-400 font-medium">
|
||||||
|
✓ применено
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-right"),
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[applied, applying, homeId, onApply, onPin, pinned],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: rows,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getRowId: (row) => row._rowKey,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={rows.length}
|
||||||
|
tableClassNames={{
|
||||||
|
headerRow: "border-b bg-muted/20",
|
||||||
|
bodyRow: cn(
|
||||||
|
"group/row hover:bg-muted/30",
|
||||||
|
"[&:has([data-comm-switch=true])]:bg-amber-500/5",
|
||||||
|
"[&:has([data-comm-applied=true])]:bg-emerald-500/5",
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
tableLayout={{ dense: true }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RouteOptimizerCommRecsDataGrid, type RouteOptimizerCommRecsDataGridProps }
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import type { FullRoute } from "@/lib/route-optimizer-data"
|
||||||
|
import { Flag } from "@/components/flag"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { ArrowRightIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border",
|
||||||
|
color ?? "bg-muted text-muted-foreground border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProbChip({ prob, best }: { prob: number; best?: boolean }) {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
color={
|
||||||
|
best
|
||||||
|
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||||
|
: "bg-muted text-muted-foreground border-border"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{prob}%
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfChip({ conf }: { conf: string }) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
HIGH: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||||
|
MEDIUM: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||||||
|
LOW: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||||
|
}
|
||||||
|
return <Chip color={map[conf] ?? map.LOW}>{conf}</Chip>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteOptimizerFullRoutesDataGridProps {
|
||||||
|
routes: FullRoute[]
|
||||||
|
bestId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteOptimizerFullRoutesDataGrid({
|
||||||
|
routes,
|
||||||
|
bestId,
|
||||||
|
}: RouteOptimizerFullRoutesDataGridProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const visibleRoutes = expanded ? routes : routes.slice(0, 5)
|
||||||
|
|
||||||
|
const indexed = useMemo(
|
||||||
|
() => visibleRoutes.map((r, index) => ({ ...r, _index: index })),
|
||||||
|
[visibleRoutes],
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<FullRoute & { _index: number }>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "index",
|
||||||
|
header: () => <span className="text-[11px] font-medium text-muted-foreground"># Маршрут</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isBest = row.original.id === bestId || row.original._index === 0
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] font-mono text-muted-foreground w-4">
|
||||||
|
{row.original._index + 1}
|
||||||
|
</span>
|
||||||
|
{isBest && (
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-1.5 py-0.5 rounded">
|
||||||
|
Лучший
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "wanJh",
|
||||||
|
header: () => <span className="text-[11px] font-medium text-muted-foreground">WAN → JH</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span className="font-mono font-semibold text-sky-600 dark:text-sky-400">{r.wan.name}</span>
|
||||||
|
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{r.jh.label}</div>
|
||||||
|
<div className="font-mono text-[10px] text-muted-foreground">
|
||||||
|
{r.hw.pingMs} мс · ↓{r.hw.dlMbps} ↑{r.hw.ulMbps}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "jhExit",
|
||||||
|
header: () => <span className="text-[11px] font-medium text-muted-foreground">JH → Exit</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original
|
||||||
|
return (
|
||||||
|
<div className="text-xs">
|
||||||
|
<div className="flex items-center gap-1 font-medium">
|
||||||
|
<Flag code={r.exit.country} />
|
||||||
|
{r.exit.label}
|
||||||
|
<span className="text-[10px] text-muted-foreground">({r.exit.site})</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[10px] text-muted-foreground">
|
||||||
|
{r.je.pingMs} мс · ↓{r.je.dlMbps} ↑{r.je.ulMbps}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "totalPing",
|
||||||
|
accessorFn: (row) => row.hw.pingMs + row.je.pingMs,
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="Ping (итого)" className="mx-auto" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const totalPing = row.original.hw.pingMs + row.original.je.pingMs
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-mono text-xs text-center block",
|
||||||
|
totalPing < 40
|
||||||
|
? "text-emerald-600 dark:text-emerald-400"
|
||||||
|
: totalPing < 80
|
||||||
|
? "text-amber-600 dark:text-amber-400"
|
||||||
|
: "text-red-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{totalPing} мс
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Ping (итого)",
|
||||||
|
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bw",
|
||||||
|
header: () => (
|
||||||
|
<span className="text-[11px] font-medium text-muted-foreground block text-center">
|
||||||
|
BW (мин)
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original
|
||||||
|
const minDl = Math.min(r.hw.dlMbps, r.je.dlMbps)
|
||||||
|
const minUl = Math.min(r.hw.ulMbps, r.je.ulMbps)
|
||||||
|
return (
|
||||||
|
<div className="font-mono text-xs text-muted-foreground text-center">
|
||||||
|
<div>↓{minDl}</div>
|
||||||
|
<div>↑{minUl}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "score",
|
||||||
|
accessorKey: "score",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="Score" className="mx-auto" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs font-semibold text-center block">
|
||||||
|
{row.original.score}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Score",
|
||||||
|
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prob",
|
||||||
|
accessorKey: "probabilityOptimal",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="P(opt)" className="mx-auto" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isBest = row.original.id === bestId || row.original._index === 0
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<ProbChip prob={row.original.probabilityOptimal} best={isBest} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerTitle: "P(opt)",
|
||||||
|
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "confidence",
|
||||||
|
accessorKey: "confidence",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="Conf." className="mx-auto" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<ConfChip conf={row.original.confidence} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Conf.",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-center"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[bestId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: indexed,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={visibleRoutes.length}
|
||||||
|
tableClassNames={{
|
||||||
|
headerRow: "border-b bg-muted/20",
|
||||||
|
bodyRow: cn("group/row hover:bg-muted/30", "has-[[data-route-best=true]]:bg-emerald-500/5"),
|
||||||
|
}}
|
||||||
|
tableLayout={{ dense: true }}
|
||||||
|
/>
|
||||||
|
{routes.length > 5 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="w-full py-2 text-xs text-muted-foreground hover:text-foreground transition-colors border-t flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<>
|
||||||
|
<ChevronUpIcon className="size-3" />
|
||||||
|
Свернуть
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDownIcon className="size-3" />
|
||||||
|
Показать все {routes.length} комбинаций
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RouteOptimizerFullRoutesDataGrid, type RouteOptimizerFullRoutesDataGridProps }
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { NetworkIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export interface OspfPreviewInterfaceRow {
|
||||||
|
id: string
|
||||||
|
interface: string
|
||||||
|
currentCost: number
|
||||||
|
optimalCost: number
|
||||||
|
score: number
|
||||||
|
pingMs: number
|
||||||
|
dlMbps: number
|
||||||
|
ulMbps: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteOptimizerOspfPreviewDataGridProps {
|
||||||
|
rows: OspfPreviewInterfaceRow[]
|
||||||
|
error?: string | null
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteOptimizerOspfPreviewDataGrid({
|
||||||
|
rows,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
}: RouteOptimizerOspfPreviewDataGridProps) {
|
||||||
|
const columns = useMemo<ColumnDef<OspfPreviewInterfaceRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "interface",
|
||||||
|
accessorKey: "interface",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="Интерфейс" className="ml-1" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <span className="font-mono">{row.original.interface}</span>,
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Интерфейс",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cost",
|
||||||
|
accessorKey: "currentCost",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Cost" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original
|
||||||
|
return (
|
||||||
|
<span className="font-mono">
|
||||||
|
<span className="text-sky-600 dark:text-sky-400">{r.currentCost}</span>
|
||||||
|
{" → "}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
r.currentCost === r.optimalCost
|
||||||
|
? "text-emerald-600 dark:text-emerald-400"
|
||||||
|
: "text-amber-600 dark:text-amber-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{r.optimalCost}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerTitle: "Cost", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "score",
|
||||||
|
accessorKey: "score",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Score" />,
|
||||||
|
cell: ({ row }) => <span className="font-mono">{row.original.score}</span>,
|
||||||
|
meta: { headerTitle: "Score", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pingMs",
|
||||||
|
accessorKey: "pingMs",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Ping" />,
|
||||||
|
cell: ({ row }) => <span className="font-mono">{row.original.pingMs}ms</span>,
|
||||||
|
meta: { headerTitle: "Ping", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "speed",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Speed (dl/ul)</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original
|
||||||
|
return (
|
||||||
|
<span className="font-mono">
|
||||||
|
↓{r.dlMbps} / ↑{r.ulMbps}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: rows,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<p className="px-3 py-2 text-xs text-destructive">Ошибка preview: {error}</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loading && rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<NetworkIcon className="size-4" />}
|
||||||
|
title="Интерфейсы OSPF не найдены для выбранного сервера"
|
||||||
|
className="border-0 py-6 text-xs"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={rows.length}
|
||||||
|
isLoading={loading}
|
||||||
|
loadingMode="skeleton"
|
||||||
|
tableClassNames={{ headerRow: "border-b bg-muted/30", bodyRow: "group/row" }}
|
||||||
|
tableLayout={{ dense: true }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RouteOptimizerOspfPreviewDataGrid, type RouteOptimizerOspfPreviewDataGridProps }
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import type { HomeRouter, JumpHost, WanJhLeg } from "@/lib/route-optimizer-data"
|
||||||
|
import { Flag } from "@/components/flag"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { WifiIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function LossChip({ loss }: { loss: number }) {
|
||||||
|
if (loss === 0) return <span className="text-emerald-600 dark:text-emerald-400 text-[11px] font-mono">0%</span>
|
||||||
|
return (
|
||||||
|
<span className={cn("text-[11px] font-mono", loss > 1 ? "text-red-500" : "text-amber-500")}>
|
||||||
|
{loss}%
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteOptimizerWanMatrixDataGridProps {
|
||||||
|
home: HomeRouter
|
||||||
|
legs: WanJhLeg[]
|
||||||
|
jumpHosts: JumpHost[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteOptimizerWanMatrixDataGrid({
|
||||||
|
home,
|
||||||
|
legs,
|
||||||
|
jumpHosts,
|
||||||
|
}: RouteOptimizerWanMatrixDataGridProps) {
|
||||||
|
const bestScore = legs.length ? Math.max(...legs.map((l) => l.score)) : 0
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<HomeRouter["wans"][number]>[]>(() => {
|
||||||
|
const base: ColumnDef<HomeRouter["wans"][number]>[] = [
|
||||||
|
{
|
||||||
|
id: "wan",
|
||||||
|
accessorKey: "name",
|
||||||
|
header: () => <span className="text-[11px] font-medium text-muted-foreground">WAN-аплинк</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const wan = row.original
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<WifiIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-xs font-semibold">{wan.name}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{wan.iface}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "isp",
|
||||||
|
header: () => <span className="text-[11px] font-medium text-muted-foreground">ISP / IP</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const wan = row.original
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium">{wan.isp}</p>
|
||||||
|
<p className="font-mono text-[10px] text-muted-foreground">{wan.ip}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bandwidth",
|
||||||
|
header: () => (
|
||||||
|
<span className="text-[11px] font-medium text-muted-foreground block text-right">
|
||||||
|
Макс. полоса
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const wan = row.original
|
||||||
|
return (
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-mono text-xs">↓{wan.maxDl}</p>
|
||||||
|
<p className="font-mono text-[10px] text-muted-foreground">↑{wan.maxUl} Мбит</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const jhCols: ColumnDef<HomeRouter["wans"][number]>[] = jumpHosts.map((jh) => ({
|
||||||
|
id: `jh-${jh.id}`,
|
||||||
|
header: () => (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-[11px] font-medium text-muted-foreground">{jh.label}</div>
|
||||||
|
<div className="font-mono font-normal text-[10px] opacity-60 flex items-center justify-center gap-1">
|
||||||
|
<Flag code={jh.country} />
|
||||||
|
{jh.site} · {jh.ip}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const wan = row.original
|
||||||
|
const leg = legs.find((l) => l.wanId === wan.id && l.jhId === jh.id)
|
||||||
|
if (!leg) return <span className="text-center text-muted-foreground text-xs block">—</span>
|
||||||
|
const isBest = leg.score === bestScore
|
||||||
|
return (
|
||||||
|
<div className={cn("text-center", isBest && "bg-emerald-500/5")}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-0.5 rounded-md px-2 py-1.5 transition-colors",
|
||||||
|
isBest ? "border border-emerald-500/20 bg-emerald-500/8" : "border border-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isBest && (
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 mb-0.5">
|
||||||
|
★ ЛУЧШИЙ
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-mono text-xs font-semibold",
|
||||||
|
leg.pingMs < 10
|
||||||
|
? "text-emerald-600 dark:text-emerald-400"
|
||||||
|
: leg.pingMs < 25
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-amber-600 dark:text-amber-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{leg.pingMs} мс
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground font-mono">
|
||||||
|
↓{leg.dlMbps} ↑{leg.ulMbps}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span className="text-[10px] font-mono text-foreground/70">score {leg.score}</span>
|
||||||
|
{leg.loss > 0 && <LossChip loss={leg.loss} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerClassName: cn(DATA_GRID_CELL_PAD, "min-w-[130px] text-center"),
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "min-w-[130px]"),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const last = jhCols[jhCols.length - 1]
|
||||||
|
if (last?.meta) {
|
||||||
|
last.meta.headerClassName = cn(last.meta.headerClassName, DATA_GRID_CELL_PAD_LAST.replace("py-3", ""))
|
||||||
|
last.meta.cellClassName = DATA_GRID_CELL_PAD_LAST
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...base, ...jhCols]
|
||||||
|
}, [bestScore, jumpHosts, legs])
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: home.wans,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={home.wans.length}
|
||||||
|
tableClassNames={{ headerRow: "border-b bg-muted/20", bodyRow: "group/row hover:bg-muted/30" }}
|
||||||
|
tableLayout={{ dense: true }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RouteOptimizerWanMatrixDataGrid, type RouteOptimizerWanMatrixDataGridProps }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import type { Server, VxlanTunnel } from "@/lib/data"
|
||||||
|
import { Flag } from "@/components/flag"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import {
|
||||||
|
CodeXmlIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
NetworkIcon,
|
||||||
|
PencilIcon,
|
||||||
|
PowerIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
interface VxlanDataGridProps {
|
||||||
|
tunnels: VxlanTunnel[]
|
||||||
|
servers: Server[]
|
||||||
|
onExport: (tunnel: VxlanTunnel) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function VxlanDataGrid({ tunnels, servers, onExport }: VxlanDataGridProps) {
|
||||||
|
const serverMap = useMemo(
|
||||||
|
() => new Map(servers.map((s) => [s.id, s])),
|
||||||
|
[servers],
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<VxlanTunnel>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="Имя / VTEP" className="ml-1" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const t = row.original
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 flex items-start gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-2 rounded-full shrink-0 mt-1.5",
|
||||||
|
t.status === "up" ? "bg-emerald-500" : "bg-red-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-mono font-medium text-sm truncate">{t.name}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground font-mono">VTEP: {t.vtepIp}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Имя / VTEP",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "server",
|
||||||
|
accessorKey: "serverId",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const srv = serverMap.get(row.original.serverId)
|
||||||
|
if (!srv) return <span className="text-muted-foreground">—</span>
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
|
||||||
|
<Flag code={srv.country} size={12} />
|
||||||
|
<span className="font-mono truncate">{srv.name}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Сервер",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "vni",
|
||||||
|
accessorKey: "vni",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="VNI" />,
|
||||||
|
cell: ({ row }) => <span className="font-mono text-sm">{row.original.vni}</span>,
|
||||||
|
meta: {
|
||||||
|
headerTitle: "VNI",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dstPort",
|
||||||
|
accessorKey: "dstPort",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Port" />,
|
||||||
|
cell: ({ row }) => <span className="font-mono text-sm">{row.original.dstPort}</span>,
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Port",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "remoteVteps",
|
||||||
|
header: () => (
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Remote</span>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-sm text-center block">{row.original.remoteVteps.length}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Remote",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "arpProxy",
|
||||||
|
accessorKey: "arpProxy",
|
||||||
|
header: () => <span className="sr-only">ARP</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] font-mono px-1.5 py-0.5 rounded",
|
||||||
|
row.original.arpProxy
|
||||||
|
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||||
|
: "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
ARP {row.original.arpProxy ? "✓" : "✗"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "macLearning",
|
||||||
|
accessorKey: "macLearning",
|
||||||
|
header: () => <span className="sr-only">MAC</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] font-mono px-1.5 py-0.5 rounded",
|
||||||
|
row.original.macLearning
|
||||||
|
? "bg-sky-500/10 text-sky-600 dark:text-sky-400"
|
||||||
|
: "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
MAC {row.original.macLearning ? "✓" : "✗"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
accessorKey: "status",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap",
|
||||||
|
row.original.status === "up"
|
||||||
|
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||||
|
: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.original.status === "up" ? "UP" : "DOWN"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Статус",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const tunnel = row.original
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||||
|
"opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100",
|
||||||
|
"data-popup-open:opacity-100",
|
||||||
|
)}
|
||||||
|
aria-label={`Действия: ${tunnel.name}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent side="bottom" align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onExport(tunnel)}>
|
||||||
|
<CodeXmlIcon className="size-4" />
|
||||||
|
Экспорт .rsc
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<PencilIcon className="size-4" />
|
||||||
|
Редактировать
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<PowerIcon className="size-4" />
|
||||||
|
{tunnel.enabled ? "Отключить" : "Включить"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem variant="destructive">
|
||||||
|
<Trash2Icon className="size-4" />
|
||||||
|
Удалить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
size: 56,
|
||||||
|
meta: {
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[onExport, serverMap],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: tunnels,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (tunnels.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<NetworkIcon className="size-4" />}
|
||||||
|
title="Нет VXLAN-туннелей"
|
||||||
|
description="Добавьте туннель или измените поиск"
|
||||||
|
className="border-0 py-16"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={tunnels.length}
|
||||||
|
tableClassNames={{
|
||||||
|
headerRow: "border-b border-border",
|
||||||
|
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { VxlanDataGrid, type VxlanDataGridProps }
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getExpandedRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import type { WireGuardInterface } from "@/lib/data"
|
||||||
|
import { Flag } from "@/components/flag"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { WireGuardPeersDetail } from "@/components/data-grids/wireguard-peers-detail"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
CodeXmlIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
PencilIcon,
|
||||||
|
PlusIcon,
|
||||||
|
PowerIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
export interface WgIfaceWithServer extends WireGuardInterface {
|
||||||
|
serverId: string
|
||||||
|
serverName: string
|
||||||
|
serverCountry: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WireguardDataGridProps {
|
||||||
|
interfaces: WgIfaceWithServer[]
|
||||||
|
onExport: (iface: WgIfaceWithServer) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
|
||||||
|
const columns = useMemo<ColumnDef<WgIfaceWithServer>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="Интерфейс / Сервер" className="ml-1" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const iface = row.original
|
||||||
|
const expanded = row.getIsExpanded()
|
||||||
|
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 min-w-0">
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-2 rounded-full shrink-0",
|
||||||
|
iface.status === "up" ? "bg-emerald-500 animate-pulse" : "bg-red-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="font-mono font-semibold text-sm">{iface.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
|
||||||
|
<Flag code={iface.serverCountry} size={12} />
|
||||||
|
{iface.serverName}
|
||||||
|
</div>
|
||||||
|
<p className="sr-only">
|
||||||
|
{onlinePeers}/{iface.peers.length} пиров
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Интерфейс / Сервер",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
expandedContent: (row: WgIfaceWithServer) => (
|
||||||
|
<WireGuardPeersDetail peers={row.peers} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "listenPort",
|
||||||
|
accessorKey: "listenPort",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Порт" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-sm">{row.original.listenPort}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Порт",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mtu",
|
||||||
|
accessorKey: "mtu",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="MTU" />,
|
||||||
|
cell: ({ row }) => <span className="font-mono text-sm">{row.original.mtu}</span>,
|
||||||
|
meta: {
|
||||||
|
headerTitle: "MTU",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "peers",
|
||||||
|
header: () => (
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Пиры</span>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const iface = row.original
|
||||||
|
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
|
||||||
|
return (
|
||||||
|
<span className="font-mono text-sm text-center block">
|
||||||
|
<span className="text-emerald-600 dark:text-emerald-400">{onlinePeers}</span>
|
||||||
|
<span className="text-muted-foreground">/{iface.peers.length}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Пиры",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
accessorKey: "status",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[11px] font-mono px-2 py-0.5 rounded border",
|
||||||
|
row.original.status === "up"
|
||||||
|
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||||
|
: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.original.status === "up" ? "UP" : "DOWN"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Статус",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const iface = row.original
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex justify-end"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||||
|
"opacity-0 transition-[opacity,background-color,color,border-color]",
|
||||||
|
"group-hover/row:opacity-100 focus-visible:opacity-100",
|
||||||
|
"data-popup-open:opacity-100 data-popup-open:bg-muted",
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={`Действия: ${iface.name}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent side="bottom" align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onExport(iface)}>
|
||||||
|
<CodeXmlIcon className="size-4" />
|
||||||
|
Экспорт .rsc
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<PencilIcon className="size-4" />
|
||||||
|
Редактировать
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
Добавить пира
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<PowerIcon className="size-4" />
|
||||||
|
{iface.enabled ? "Отключить" : "Включить"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem variant="destructive">
|
||||||
|
<Trash2Icon className="size-4" />
|
||||||
|
Удалить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
size: 56,
|
||||||
|
meta: {
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[onExport],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: interfaces,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getExpandedRowModel: getExpandedRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
getRowCanExpand: () => true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (interfaces.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<ShieldCheckIcon className="size-4" />}
|
||||||
|
title="Нет WireGuard интерфейсов"
|
||||||
|
description="Добавьте первый интерфейс или проверьте поиск"
|
||||||
|
className="border-0 py-16"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={interfaces.length}
|
||||||
|
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
||||||
|
tableClassNames={{
|
||||||
|
headerRow: "border-b border-border",
|
||||||
|
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { WireguardDataGrid, type WireguardDataGridProps }
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { WireGuardPeer } from "@/lib/data"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
ArrowDownIcon,
|
||||||
|
ArrowUpIcon,
|
||||||
|
KeyRoundIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
function fmtBytes(n: number | undefined): string {
|
||||||
|
if (!n) return "—"
|
||||||
|
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)} ГБ`
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(0)} КБ`
|
||||||
|
return `${n} Б`
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncKey(key: string): string {
|
||||||
|
if (key.length <= 20) return key
|
||||||
|
return `${key.slice(0, 8)}…${key.slice(-8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function WireGuardPeersDetail({ peers }: { peers: WireGuardPeer[] }) {
|
||||||
|
if (peers.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="px-5 py-4 text-xs text-muted-foreground text-center border-t border-border/50">
|
||||||
|
Нет пиров
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-border/50">
|
||||||
|
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-5 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
<span>Public Key</span>
|
||||||
|
<span>Allowed IPs</span>
|
||||||
|
<span>Последнее рукопожатие</span>
|
||||||
|
<span>RX / TX</span>
|
||||||
|
<span>Endpoint</span>
|
||||||
|
</div>
|
||||||
|
{peers.map((peer) => (
|
||||||
|
<div
|
||||||
|
key={peer.publicKey}
|
||||||
|
className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-5 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
|
||||||
|
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
|
||||||
|
{truncKey(peer.publicKey)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-muted-foreground truncate">
|
||||||
|
{peer.allowedIps.join(", ")}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-mono text-[11px] whitespace-nowrap",
|
||||||
|
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{peer.latestHandshake ?? "нет рукопожатия"}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<ArrowDownIcon className="size-3 text-emerald-500" />
|
||||||
|
{fmtBytes(peer.transferRx)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<ArrowUpIcon className="size-3 text-blue-400" />
|
||||||
|
{fmtBytes(peer.transferTx)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { WireGuardPeersDetail, fmtBytes, truncKey }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ReactNode } from "react"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface DataPageCardProps {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataPageCard({ children, className }: DataPageCardProps) {
|
||||||
|
return (
|
||||||
|
<Card className={cn("overflow-hidden py-0 gap-0", className)}>
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DataPageCard, type DataPageCardProps }
|
||||||
@@ -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
@@ -1,13 +1,30 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { Card } from "@/components/ui/card"
|
import {
|
||||||
import { SearchIcon } from "lucide-react"
|
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> {
|
export interface Column<T> {
|
||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
render: (row: T) => React.ReactNode
|
render: (row: T) => React.ReactNode
|
||||||
|
enableSorting?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DataTableProps<T extends { id: string }> {
|
interface DataTableProps<T extends { id: string }> {
|
||||||
@@ -15,6 +32,11 @@ interface DataTableProps<T extends { id: string }> {
|
|||||||
columns: Column<T>[]
|
columns: Column<T>[]
|
||||||
searchPlaceholder?: string
|
searchPlaceholder?: string
|
||||||
searchKeys?: (keyof T)[]
|
searchKeys?: (keyof T)[]
|
||||||
|
isLoading?: boolean
|
||||||
|
emptyTitle?: string
|
||||||
|
emptyDescription?: string
|
||||||
|
pagination?: boolean
|
||||||
|
countLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTable<T extends { id: string }>({
|
export function DataTable<T extends { id: string }>({
|
||||||
@@ -22,10 +44,15 @@ export function DataTable<T extends { id: string }>({
|
|||||||
columns,
|
columns,
|
||||||
searchPlaceholder = "Поиск…",
|
searchPlaceholder = "Поиск…",
|
||||||
searchKeys = [],
|
searchKeys = [],
|
||||||
|
isLoading = false,
|
||||||
|
emptyTitle = "Нет записей",
|
||||||
|
emptyDescription,
|
||||||
|
pagination = false,
|
||||||
|
countLabel,
|
||||||
}: DataTableProps<T>) {
|
}: DataTableProps<T>) {
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
if (!search || searchKeys.length === 0) return data
|
if (!search || searchKeys.length === 0) return data
|
||||||
const s = search.toLowerCase()
|
const s = search.toLowerCase()
|
||||||
return data.filter((row) =>
|
return data.filter((row) =>
|
||||||
@@ -33,44 +60,71 @@ export function DataTable<T extends { id: string }>({
|
|||||||
)
|
)
|
||||||
}, [data, search, searchKeys])
|
}, [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 (
|
return (
|
||||||
<Card>
|
<DataPageCard>
|
||||||
<div className="flex items-center gap-2 px-5 py-3 border-b">
|
<DataPageToolbar
|
||||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
|
search={search}
|
||||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
onSearchChange={setSearch}
|
||||||
<input
|
searchPlaceholder={searchPlaceholder}
|
||||||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
countLabel={countLabel ?? `${displayCount} записей`}
|
||||||
placeholder={searchPlaceholder}
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<DataGridShell
|
||||||
<span className="text-sm text-muted-foreground ml-1">{filtered.length} записей</span>
|
table={table}
|
||||||
</div>
|
recordCount={filteredData.length}
|
||||||
<div className="overflow-x-auto">
|
isLoading={isLoading}
|
||||||
<table className="w-full text-sm">
|
loadingMode="skeleton"
|
||||||
<thead>
|
pagination={pagination}
|
||||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
emptyMessage={
|
||||||
{columns.map((col) => (
|
<EmptyState
|
||||||
<th key={col.key} className="text-left font-medium px-5 py-3">
|
icon={<InboxIcon className="size-4" />}
|
||||||
{col.label}
|
title={emptyTitle}
|
||||||
</th>
|
description={emptyDescription}
|
||||||
))}
|
className="border-0 py-12"
|
||||||
</tr>
|
/>
|
||||||
</thead>
|
}
|
||||||
<tbody className="divide-y divide-border">
|
/>
|
||||||
{filtered.map((row) => (
|
</DataPageCard>
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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"
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
@@ -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 }
|
||||||
@@ -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
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, ReactNode, useContext, useId } from "react"
|
||||||
|
import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field"
|
||||||
|
import { cva, VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { MinusIcon, PlusIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const NumberFieldContext = createContext<{
|
||||||
|
fieldId: string
|
||||||
|
size: "sm" | "default" | "lg"
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const numberFieldGroupVariants = cva(
|
||||||
|
"relative flex w-full justify-between border border-input data-disabled:pointer-events-none data-disabled:opacity-50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus-within:has-aria-invalid:border-destructive focus-within:has-aria-invalid:ring-destructive/20 dark:focus-within:has-aria-invalid:ring-destructive/40 rounded-lg bg-transparent dark:bg-input/30 transition-colors focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-3",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: "h-7 text-sm",
|
||||||
|
default:
|
||||||
|
"h-8 text-sm",
|
||||||
|
lg: "h-9 text-sm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const numberFieldButtonVariants = cva(
|
||||||
|
"relative flex shrink-0 cursor-pointer items-center justify-center transition-colors pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: "px-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
default:
|
||||||
|
"px-2 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
lg: "px-2.5 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const numberFieldInputVariants = cva(
|
||||||
|
"w-full min-w-0 flex-1 bg-transparent text-center tabular-nums outline-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: "px-2 py-0.5",
|
||||||
|
default:
|
||||||
|
"px-2.5 py-1",
|
||||||
|
lg: "px-2.5 py-1.5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function NumberField({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: NumberFieldPrimitive.Root.Props &
|
||||||
|
VariantProps<typeof numberFieldGroupVariants>) {
|
||||||
|
const generatedId = useId()
|
||||||
|
const fieldId = id ?? generatedId
|
||||||
|
const sizeValue = size ?? "default"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberFieldContext.Provider value={{ fieldId, size: sizeValue }}>
|
||||||
|
<NumberFieldPrimitive.Root
|
||||||
|
className={cn("flex w-full flex-col items-start gap-2", className)}
|
||||||
|
data-size={sizeValue}
|
||||||
|
data-slot="number-field"
|
||||||
|
id={fieldId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</NumberFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberFieldGroup({
|
||||||
|
className,
|
||||||
|
size: sizeProp,
|
||||||
|
...props
|
||||||
|
}: NumberFieldPrimitive.Group.Props &
|
||||||
|
Partial<VariantProps<typeof numberFieldGroupVariants>>) {
|
||||||
|
const context = useContext(NumberFieldContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"NumberFieldGroup must be used within a NumberField component."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const size = sizeProp ?? context.size
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberFieldPrimitive.Group
|
||||||
|
className={cn(numberFieldGroupVariants({ size }), className)}
|
||||||
|
data-slot="number-field-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberFieldDecrement({
|
||||||
|
className,
|
||||||
|
size: sizeProp,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: NumberFieldPrimitive.Decrement.Props &
|
||||||
|
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const context = useContext(NumberFieldContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"NumberFieldDecrement must be used within a NumberField component."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const size = sizeProp ?? context.size
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberFieldPrimitive.Decrement
|
||||||
|
className={cn(
|
||||||
|
numberFieldButtonVariants({ size }),
|
||||||
|
"rounded-s-lg border-e-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="number-field-decrement"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<MinusIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</NumberFieldPrimitive.Decrement>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberFieldIncrement({
|
||||||
|
className,
|
||||||
|
size: sizeProp,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: NumberFieldPrimitive.Increment.Props &
|
||||||
|
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
|
||||||
|
children?: ReactNode
|
||||||
|
}) {
|
||||||
|
const context = useContext(NumberFieldContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"NumberFieldIncrement must be used within a NumberField component."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const size = sizeProp ?? context.size
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberFieldPrimitive.Increment
|
||||||
|
className={cn(
|
||||||
|
numberFieldButtonVariants({ size }),
|
||||||
|
"rounded-e-lg border-s-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="number-field-increment"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<PlusIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</NumberFieldPrimitive.Increment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberFieldInput({
|
||||||
|
className,
|
||||||
|
size: sizeProp,
|
||||||
|
...props
|
||||||
|
}: NumberFieldPrimitive.Input.Props &
|
||||||
|
Partial<VariantProps<typeof numberFieldInputVariants>>) {
|
||||||
|
const context = useContext(NumberFieldContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"NumberFieldInput must be used within a NumberField component."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const size = sizeProp ?? context.size
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberFieldPrimitive.Input
|
||||||
|
className={cn(numberFieldInputVariants({ size }), className)}
|
||||||
|
data-slot="number-field-input"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberFieldScrubArea({
|
||||||
|
className,
|
||||||
|
label,
|
||||||
|
...props
|
||||||
|
}: NumberFieldPrimitive.ScrubArea.Props & {
|
||||||
|
label: string
|
||||||
|
}) {
|
||||||
|
const context = useContext(NumberFieldContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"NumberFieldScrubArea must be used within a NumberField component for accessibility."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberFieldPrimitive.ScrubArea
|
||||||
|
className={cn("flex cursor-ew-resize", className)}
|
||||||
|
data-slot="number-field-scrub-area"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Label className="cursor-ew-resize" htmlFor={context.fieldId}>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
<NumberFieldPrimitive.ScrubAreaCursor className="drop-shadow-[0_1px_1px_#0008] filter">
|
||||||
|
<CursorGrowIcon />
|
||||||
|
</NumberFieldPrimitive.ScrubAreaCursor>
|
||||||
|
</NumberFieldPrimitive.ScrubArea>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CursorGrowIcon(props: React.ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
fill="black"
|
||||||
|
height="14"
|
||||||
|
stroke="white"
|
||||||
|
viewBox="0 0 24 14"
|
||||||
|
width="26"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M19.5 5.5L6.49737 5.51844V2L1 6.9999L6.5 12L6.49737 8.5L19.5 8.5V12L25 6.9999L19.5 2V5.5Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
NumberField,
|
||||||
|
NumberFieldScrubArea,
|
||||||
|
NumberFieldDecrement,
|
||||||
|
NumberFieldIncrement,
|
||||||
|
NumberFieldGroup,
|
||||||
|
NumberFieldInput,
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user