Compare commits

...
11 Commits
Author SHA1 Message Date
Denozordec a1a9124f3d fix(config): добавить новые зависимости и обновить конфигурацию компонентов
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m37s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
2026-06-30 21:30:40 +07:00
Denozordec 009011a917 chore(docs): обновить инструкции по локальному запуску проекта
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 3m42s
Docker images / frontend-image (push) Successful in 2m40s
Docker images / updater-image (push) Successful in 53s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
2026-06-30 20:07:20 +07:00
Denozordec 399871f4f9 fix(config): улучшить обработку URL для бэкенда и увеличить таймаут запросов
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m36s
Docker images / frontend-image (push) Successful in 1m39s
Docker images / updater-image (push) Successful in 50s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
2026-05-12 23:19:51 +07:00
Denozordec 158fc36294 fix(api): увеличить лимит размера тела запроса и улучшить обработку URL для API
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m36s
Docker images / frontend-image (push) Successful in 1m40s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
2026-05-12 23:11:23 +07:00
Denozordec 7dc4836c71 fix(dashboard): улучшить логику загрузки данных на странице панели управления
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 1m47s
Docker images / updater-image (push) Successful in 54s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
2026-05-12 22:47:30 +07:00
Denozordec efc3812e12 fix(updater): обновить URL для проверки состояния сервиса 2026-05-12 22:46:16 +07:00
Denozordec 8d5fd84962 fix(data-collection): добавить возможность отключения переключателей и улучшить обработку настроек
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m31s
Docker images / frontend-image (push) Successful in 1m41s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
2026-05-12 22:37:55 +07:00
Denozordec f69e65b014 fix(docker): добавить переменную окружения для внутреннего URL бэкенда
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m34s
Docker images / frontend-image (push) Successful in 1m38s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
2026-05-12 22:21:57 +07:00
Denozordec 49d14a00af fix(data-collection): улучшить обработку ошибок и загрузку данных с API
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 1m41s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
2026-05-12 22:11:49 +07:00
Denozordec 9ab2418a5f feat(backup): добавить таблицу для резервных копий и функции для работы с ними
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m35s
Docker images / frontend-image (push) Successful in 1m42s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
2026-05-12 21:56:13 +07:00
Denozordec 63aa9d424b chore(backups): удалить устаревшие резервные копии Gateway
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 1m38s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
2026-05-12 21:55:41 +07:00
131 changed files with 13918 additions and 276195 deletions
+144
View File
@@ -0,0 +1,144 @@
import re
from pathlib import Path
path = Path(__file__).resolve().parents[2] / "app" / "(main)" / "servers" / "page.tsx"
text = path.read_text(encoding="utf-8")
text = text.replace(
'import { Fragment, useEffect, useMemo, useState } from "react"\n'
'import { PageHeader } from "@/components/page-header"\n'
'import { StatusBadge } from "@/components/status-badge"',
'import { useEffect, useMemo, useState } from "react"\n'
'import { PageHeader } from "@/components/page-header"\n'
'import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"\n'
'import { DataPageToolbar } from "@/components/data-page-toolbar"\n'
'import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"',
1,
)
text = text.replace(
"""import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
} from "@/components/ui/dropdown-menu"
""",
"""import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
""",
1,
)
text = text.replace(
"""import {
SearchIcon, RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
MoreHorizontalIcon, EyeIcon, EyeOffIcon,
ChevronRightIcon, ChevronDownIcon,
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
ShieldIcon, WifiIcon, PencilIcon, PowerIcon, Trash2Icon, ExternalLinkIcon,
HomeIcon, ServerIcon, NetworkIcon,
} from "lucide-react\"""",
"""import {
RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
EyeIcon, EyeOffIcon,
ChevronRightIcon, ChevronDownIcon,
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
ShieldIcon, WifiIcon,
HomeIcon, ServerIcon, NetworkIcon,
} from "lucide-react\"""",
1,
)
text = re.sub(
r"// ─── RouterOS version utilities.*?// ─── Countries ─",
"// ─── Countries ─",
text,
count=1,
flags=re.S,
)
text = re.sub(
r"// ─── Type config ─.*?// ─── Shared small components ─",
"// ─── Shared small components ─",
text,
count=1,
flags=re.S,
)
text = re.sub(
r"function Field\(\{ label, hint, required, children \}:.*?^}\n\n// ─── Country field",
"// ─── Country field",
text,
count=1,
flags=re.S | re.M,
)
text = text.replace(
"const [expandedId, setExpandedId] = useState<string | null>(null)",
"const [sheetStep, setSheetStep] = useState(1)",
1,
)
text = text.replace(
'setForm(defaultForm); setTestState("idle"); setTestMsg("")\n setOpen(true)',
'setForm(defaultForm); setTestState("idle"); setTestMsg("")\n setSheetStep(1)\n setOpen(true)',
1,
)
text = text.replace(
'setTestState("idle"); setTestMsg(""); setOpen(true)',
'setTestState("idle"); setTestMsg(""); setSheetStep(1); setOpen(true)',
1,
)
text = re.sub(r"<Field\b", "<FormField", text)
text = re.sub(r"</Field>", "</FormField>", text)
text = re.sub(r"<Toggle\b", "<FormToggle", text)
new_table = """ {/* Table */}
<Card>
<DataPageToolbar
segmented={{
value: typeFilter,
onChange: setTypeFilter,
options: tabs.map((tab) => ({
value: tab.value,
label: tab.label,
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
})),
}}
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, хосту…"
countLabel={`${filtered.length} серверов`}
/>
<ServersDataGrid
servers={filtered}
isLive={isLive}
pollingIds={pollingIds}
onPoll={handlePoll}
onEdit={openEdit}
onDelete={handleDelete}
onToggleStatus={handleToggleStatus}
/>
</Card>
"""
text = re.sub(
r" \{/\* Table \*/\}\n <Card>.*?</Card>\n",
new_table,
text,
count=1,
flags=re.S,
)
path.write_text(text, encoding="utf-8")
print("patched", path)
+144
View File
@@ -0,0 +1,144 @@
import re
from pathlib import Path
path = Path(__file__).resolve().parents[2] / "app" / "(main)" / "servers" / "page.tsx"
text = path.read_text(encoding="utf-8")
# Wrap sheet form in stepper
text = text.replace(
""" <div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
{/* 1. Основные */}
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>""",
""" <Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
<StepperItem step={1}>
<StepperTrigger>
<StepperIndicator>1</StepperIndicator>
<StepperTitle className="sr-only">Основные</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger>
<StepperIndicator>2</StepperIndicator>
<StepperTitle className="sr-only">WAN</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={3}>
<StepperTrigger>
<StepperIndicator>3</StepperIndicator>
<StepperTitle className="sr-only">API</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={4}>
<StepperTrigger>
<StepperIndicator>4</StepperIndicator>
<StepperTitle className="sr-only">Дополнительно</StepperTitle>
</StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
<StepperContent value={1} className="flex flex-col gap-4">""",
1,
)
text = text.replace(
""" </div>
{/* 2. WAN-аплинки (только для home-router) */}
{isHomeRouter && (
<div className="flex flex-col gap-4">
<SectionTitle>WAN-аплинки</SectionTitle>""",
""" </StepperContent>
<StepperContent value={2} className="flex flex-col gap-4">
<SectionTitle>WAN-аплинки</SectionTitle>""",
1,
)
text = text.replace(
""" </div>
)}
{/* 3. Подключение (API) */}
<div className="flex flex-col gap-4">
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>""",
""" </StepperContent>
<StepperContent value={3} className="flex flex-col gap-4">
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>""",
1,
)
text = text.replace(
""" </div>
{/* 4. Дополнительно */}
<div className="flex flex-col gap-4">
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}""",
""" </StepperContent>
<StepperContent value={4} className="flex flex-col gap-4">
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}""",
1,
)
text = text.replace(
""" </div>
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
<Button className="flex-1" onClick={handleSave}>
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
</Button>
</SheetFooter>""",
""" </StepperContent>
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
{sheetStep > 1 && (
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
)}
{sheetStep < 4 ? (
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
) : (
<Button className="ml-auto" onClick={handleSave}>
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
</Button>
)}
</SheetFooter>""",
1,
)
# WAN step 2: show message when not home router
text = text.replace(
""" <StepperContent value={2} className="flex flex-col gap-4">
<SectionTitle>WAN-аплинки</SectionTitle>
<WanUplinkEditor""",
""" <StepperContent value={2} className="flex flex-col gap-4">
<SectionTitle>WAN-аплинки</SectionTitle>
{!isHomeRouter ? (
<p className="text-sm text-muted-foreground">WAN-аплинки доступны только для типа Home Router.</p>
) : (
<WanUplinkEditor""",
1,
)
text = text.replace(
""" />
</StepperContent>
<StepperContent value={3}""",
""" />
)}
</StepperContent>
<StepperContent value={3}""",
1,
)
path.write_text(text, encoding="utf-8")
print("stepper patched")
+16
View File
@@ -0,0 +1,16 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
+30 -5
View File
@@ -5,15 +5,40 @@ alwaysApply: true
# Локальный запуск проекта
Для запуска всего проекта в dev-режиме поднимать два процесса:
## Требования
- **Node.js 22**, npm с workspaces.
- Первый раз (или после смены зависимостей): `npm install` из корня репозитория.
- Backend: скопировать `backend/.env.example` → `backend/.env` (по умолчанию `PORT=8000`, `CORS_ORIGIN=http://localhost:3000`).
## Запуск (два процесса)
Из корня репозитория поднять **два** long-running процесса в **отдельных** терминалах:
```powershell
npm run dev
npm --prefix backend run dev
```
- Frontend: `http://localhost:3000`
- Backend: `http://localhost:8000`
- Health check backend: `http://localhost:8000/health`
| Сервис | URL | Проверка |
|--------|-----|----------|
| Frontend (Next.js 16, Turbopack) | http://localhost:3000 | открыть в браузере |
| Backend (Fastify) | http://localhost:8000 | `GET /health` |
Если пользователь просит “запусти проект”, “запусти фронт и бэк” или похожую команду, сначала проверь уже запущенные терминалы, затем запускай эти две команды отдельными long-running процессами.
Проверка backend в PowerShell:
```powershell
Invoke-WebRequest -Uri http://localhost:8000/health -UseBasicParsing | Select-Object -ExpandProperty Content
```
Ожидаемый ответ: `{"status":"ok",...}`.
## Поведение агента
Если пользователь просит «запусти проект», «запусти фронт и бэк» или похожее:
1. Сначала проверить уже запущенные терминалы — не дублировать процессы.
2. Запустить обе команды выше как фоновые long-running процессы.
3. Дождаться готовности: frontend — `Ready`, backend — `Server listening` / успешный `/health`.
Подробности архитектуры и env — `README.md`, раздел «Запуск».
+4
View File
@@ -10,3 +10,7 @@
4. Локальный hook `.githooks/commit-msg` отклоняет subject без кириллицы; подключение — `npm install` / `npm run prepare`.
Полные правила: `.cursor/rules/commit-messages-ru.mdc`, semver — `.cursor/rules/release-versioning.mdc`.
## Локальный запуск
Два процесса из корня: `npm run dev` (frontend :3000) и `npm --prefix backend run dev` (backend :8000). Перед первым запуском — `npm install`, для backend — `backend/.env` из `backend/.env.example`. Подробности — `.cursor/rules/dev-run-command.mdc` и `README.md`.
+1
View File
@@ -40,6 +40,7 @@ ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
ENV BACKEND_INTERNAL_URL=http://backend:8000
COPY --from=build /app/public ./public
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
+2 -18
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useDataSource } from "@/lib/data-source"
import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Separator } from "@/components/ui/separator"
@@ -685,23 +686,6 @@ const INIT_TG: TelegramConfig = {
// ─── small helpers ────────────────────────────────────────────────────────────
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
return (
<button role="switch" aria-checked={checked} aria-disabled={disabled} disabled={disabled}
onClick={() => { if (!disabled) onChange(!checked) }}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
disabled && "opacity-50 pointer-events-none",
checked ? "bg-primary" : "bg-muted-foreground/30",
)}>
<span className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform",
checked ? "translate-x-4" : "translate-x-0.5",
)} />
</button>
)
}
function FieldLabel({ children, className }: { children: React.ReactNode; className?: string }) {
return <p className={cn("text-sm font-medium mb-1.5 leading-none", className)}>{children}</p>
}
@@ -739,7 +723,7 @@ function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled }
!rule.enabled && "opacity-55",
)}>
{/* toggle */}
<Toggle checked={rule.enabled} onChange={v => onToggle(rule.id, v)} disabled={interactionsDisabled} />
<FormToggle checked={rule.enabled} onChange={v => onToggle(rule.id, v)} disabled={interactionsDisabled} />
{/* severity dot */}
<SeverityDot severity={rule.severity} />
+18 -2
View File
@@ -1,18 +1,21 @@
"use client"
import { useMemo } from "react"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { DataTable } from "@/components/data-table"
import { FileImportDialog } from "@/components/file-import-dialog"
import { asns as mockAsns } from "@/lib/data"
import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
export default function AsnsPage() {
const { mode } = useDataSource()
const { enabled, snapshot, loading, error } = useEvoBGP()
const [importOpen, setImportOpen] = useState(false)
const useEvoCatalog = mode === "live" && enabled
@@ -28,7 +31,9 @@ export default function AsnsPage() {
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
actions={
<>
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />Импорт
</Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
<Button size="sm"><PlusIcon className="size-4" />Добавить ASN</Button>
</>
@@ -57,6 +62,7 @@ export default function AsnsPage() {
</div>
<DataTable
data={rows}
isLoading={useEvoCatalog && loading && !snapshot}
searchPlaceholder="Поиск по ASN, имени, префиксам…"
searchKeys={["asn", "org", "prefixes"]}
columns={[
@@ -106,6 +112,16 @@ export default function AsnsPage() {
/>
</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>
)
}
+178 -202
View File
@@ -2,6 +2,10 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { BackupsDataGrid } from "@/components/data-grids/backups-data-grid"
import { FileImportDialog } from "@/components/file-import-dialog"
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { StatusBadge } from "@/components/status-badge"
import type { Backup, Server } from "@/lib/data"
import { Card, CardContent } from "@/components/ui/card"
@@ -22,52 +26,17 @@ import { listServers } from "@/shared/api/servers"
import { toFrontendServer } from "@/entities/server/model/mappers"
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
import { toast } from "sonner"
// ─── small UI helpers ─────────────────────────────────────────────────────────
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button type="button" role="switch" aria-checked={checked} onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors ${checked ? "bg-primary" : "bg-input"}`}>
<span className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-4" : "translate-x-0"}`} />
</button>
)
}
function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 pb-1">
<span className="text-muted-foreground">{icon}</span>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{label}</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function SegmentedControl<T extends string>({ value, onChange, options }: {
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
}) {
return (
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
{options.map((o) => (
<button key={o.value} type="button" onClick={() => onChange(o.value)}
className={`px-3 py-1 text-sm rounded transition-colors ${value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
{o.label}
</button>
))}
</div>
)
}
import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
// ─── types ────────────────────────────────────────────────────────────────────
@@ -168,8 +137,11 @@ export default function BackupsPage() {
// Manual backup sheet
const [manualOpen, setManualOpen] = useState(false)
const [manualStep, setManualStep] = useState(1)
const [manualServers, setManualServers] = useState<Set<string>>(new Set())
const [manualNotes, setManualNotes] = useState("")
const [restoreOpen, setRestoreOpen] = useState(false)
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null)
function toggleManualServer(id: string) {
setManualServers((prev) => {
const next = new Set(prev)
@@ -356,7 +328,7 @@ export default function BackupsPage() {
</Button>
<Button
size="sm"
onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualOpen(true) }}
onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualStep(1); setManualOpen(true) }}
disabled={loading}
>
<PlusIcon className="size-4" />Новый бэкап
@@ -425,81 +397,27 @@ export default function BackupsPage() {
{/* ── История ──────────────────────────────────────────────────── */}
{tab === "history" && (
<Card>
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{([
{ value: "all", label: "Все", count: backupList.length },
{ value: "auto", label: "Авто", count: autoCount },
{ value: "manual", label: "Вручную", count: manualCount },
] as { value: KindFilter; label: string; count: number }[]).map((t) => (
<button key={t.value} onClick={() => setKindFilter(t.value)}
className={`flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors ${kindFilter === t.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
{t.label}
<span className="text-xs tabular-nums opacity-60">{t.count}</span>
</button>
))}
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} бэкапов</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3">Файл</th>
<th className="text-left font-medium px-4 py-3">Сервер</th>
<th className="text-left font-medium px-4 py-3">Размер</th>
<th className="text-left font-medium px-4 py-3">Тип</th>
<th className="text-left font-medium px-4 py-3">Заметки</th>
<th className="text-left font-medium px-4 py-3">Создан</th>
<th className="w-28 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.length === 0 && (
<tr><td colSpan={7} className="px-5 py-10 text-center text-sm text-muted-foreground">Нет бэкапов</td></tr>
)}
{filtered.map((b) => (
<tr key={b.id} className="hover:bg-muted/40 transition-colors group">
<td className="px-5 py-3 font-mono text-xs font-medium">{b.filename}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{b.server}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{b.size}</td>
<td className="px-4 py-3">
<span className={cn("text-xs px-2 py-0.5 rounded border font-medium",
b.kind === "manual"
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
: "bg-muted text-muted-foreground border-border"
)}>
{b.kind === "auto" ? "авто" : "вручную"}
</span>
</td>
<td className="px-4 py-3 text-xs text-muted-foreground max-w-[200px] truncate">{b.notes || "—"}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{b.created}</td>
<td className="px-3 py-3">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="size-7"
title="Скачать"
onClick={() => void handleDownload(b.id, b.filename)}
>
<DownloadIcon className="size-3.5" />
</Button>
<Button variant="ghost" size="icon" className="size-7" title="Восстановить">
<RefreshCwIcon className="size-3.5" />
</Button>
<Button variant="ghost" size="icon" className="size-7 text-destructive hover:text-destructive"
title="Удалить" onClick={() => void handleDelete(b.id)}>
<Trash2Icon className="size-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<DataPageToolbar
segmented={{
value: kindFilter,
onChange: setKindFilter,
options: [
{ value: "all", label: "Все", count: backupList.length },
{ value: "auto", label: "Авто", count: autoCount },
{ value: "manual", label: "Вручную", count: manualCount },
],
}}
countLabel={`${filtered.length} бэкапов`}
/>
<BackupsDataGrid
backups={filtered}
onDownload={handleDownload}
onRestore={(b) => {
setRestoreTarget(b)
setRestoreOpen(true)
}}
onDelete={handleDelete}
/>
</Card>
)}
@@ -517,11 +435,11 @@ export default function BackupsPage() {
<p className="text-sm font-medium">Автоматический бэкап</p>
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
</div>
<Toggle checked={schedule.enabled} onChange={(v) => setSched("enabled", v)} />
<FormToggle checked={schedule.enabled} onChange={(v) => setSched("enabled", v)} />
</div>
<div className={cn("flex flex-col gap-4 transition-opacity", !schedule.enabled && "opacity-40 pointer-events-none")}>
<Field label="Частота">
<FormField label="Частота">
<SegmentedControl
value={schedule.frequency}
onChange={(v) => setSched("frequency", v)}
@@ -531,10 +449,10 @@ export default function BackupsPage() {
{ value: "monthly", label: "Ежемесячно" },
]}
/>
</Field>
</FormField>
{schedule.frequency === "weekly" && (
<Field label="День недели">
<FormField label="День недели">
<div className="flex gap-1">
{WEEK_DAYS.map((d, i) => (
<button key={i} type="button" onClick={() => setSched("weekDay", i)}
@@ -548,18 +466,18 @@ export default function BackupsPage() {
</button>
))}
</div>
</Field>
</FormField>
)}
{schedule.frequency === "monthly" && (
<Field label="День месяца" hint="128">
<FormField label="День месяца" hint="128">
<Input type="number" min={1} max={28} className="font-mono w-24"
value={schedule.monthDay}
onChange={(e) => setSched("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))} />
</Field>
</FormField>
)}
<Field label="Время запуска">
<FormField label="Время запуска">
<div className="flex items-center gap-2">
<div className="relative">
<Input type="number" min={0} max={23} className="font-mono w-20 text-center"
@@ -581,15 +499,15 @@ export default function BackupsPage() {
))}
</div>
</div>
</Field>
</FormField>
<div className="grid grid-cols-2 gap-4">
<Field label="Хранить бэкапов" hint="На каждый сервер">
<FormField label="Хранить бэкапов" hint="На каждый сервер">
<Input type="number" min={1} max={90} className="font-mono"
value={schedule.keepCount}
onChange={(e) => setSched("keepCount", Math.max(1, Number(e.target.value)))} />
</Field>
<Field label="Формат файла">
</FormField>
<FormField label="Формат файла">
<SegmentedControl
value={schedule.format}
onChange={(v) => setSched("format", v)}
@@ -598,7 +516,7 @@ export default function BackupsPage() {
{ value: "backup", label: ".backup" },
]}
/>
</Field>
</FormField>
</div>
</div>
</CardContent>
@@ -609,7 +527,7 @@ export default function BackupsPage() {
<CardContent className="px-5 py-5 flex flex-col gap-5">
<SectionTitle icon={<FolderIcon className="size-3.5" />}>Хранилище</SectionTitle>
<Field label="Тип хранилища">
<FormField label="Тип хранилища">
<SegmentedControl
value={storage.type}
onChange={(v) => setStore("type", v)}
@@ -620,45 +538,45 @@ export default function BackupsPage() {
{ value: "smb", label: "SMB" },
]}
/>
</Field>
</FormField>
{storage.type === "local" && (
<Field label="Путь сохранения" hint="Директория на сервере приложения">
<FormField label="Путь сохранения" hint="Директория на сервере приложения">
<Input className="font-mono" placeholder="/var/backup/mikrotik"
value={storage.localPath}
onChange={(e) => setStore("localPath", e.target.value)} />
</Field>
</FormField>
)}
{storage.type !== "local" && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<Field label="Хост">
<FormField label="Хост">
<Input className="font-mono" placeholder="192.168.1.100"
value={storage.host} onChange={(e) => setStore("host", e.target.value)} />
</Field>
</FormField>
</div>
<Field label="Порт">
<FormField label="Порт">
<Input className="font-mono"
placeholder={storage.type === "ftp" ? "21" : storage.type === "scp" ? "22" : "445"}
value={storage.port} onChange={(e) => setStore("port", e.target.value)} />
</Field>
</FormField>
</div>
{storage.type === "smb" && (
<Field label="Общая папка (Share)">
<FormField label="Общая папка (Share)">
<Input className="font-mono" placeholder="backups"
value={storage.share} onChange={(e) => setStore("share", e.target.value)} />
</Field>
</FormField>
)}
<div className="grid grid-cols-2 gap-3">
<Field label="Пользователь">
<FormField label="Пользователь">
<Input className="font-mono" placeholder="backup-user"
value={storage.username} onChange={(e) => setStore("username", e.target.value)} />
</Field>
<Field label={storage.type === "scp" ? "Пароль / ключ" : "Пароль"}>
</FormField>
<FormField label={storage.type === "scp" ? "Пароль / ключ" : "Пароль"}>
<div className="relative">
<Input
type={storage.showPassword ? "text" : "password"}
@@ -673,13 +591,13 @@ export default function BackupsPage() {
{storage.showPassword ? "скрыть" : "показ"}
</button>
</div>
</Field>
</FormField>
</div>
<Field label="Удалённый путь">
<FormField label="Удалённый путь">
<Input className="font-mono" placeholder="/mikrotik-backups"
value={storage.remotePath} onChange={(e) => setStore("remotePath", e.target.value)} />
</Field>
</FormField>
</>
)}
@@ -759,71 +677,129 @@ export default function BackupsPage() {
</div>
{/* ══ Sheet: Manual backup ══════════════════════════════════════════════ */}
<Sheet open={manualOpen} onOpenChange={setManualOpen}>
<Sheet open={manualOpen} onOpenChange={(v) => { setManualOpen(v); if (!v) setManualStep(1) }}>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Новый бэкап</SheetTitle>
<SheetDescription>Снять конфигурацию вручную с выбранных серверов</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between mb-1">
<p className="text-sm font-medium">Выберите серверы</p>
<div className="flex items-center gap-2">
<button type="button" onClick={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
className="text-xs text-primary hover:underline">Все</button>
<span className="text-border">·</span>
<button type="button" onClick={() => setManualServers(new Set())}
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
<Stepper value={manualStep} onValueChange={setManualStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
<StepperItem step={1}>
<StepperTrigger>
<StepperIndicator>1</StepperIndicator>
<StepperTitle className="sr-only">Серверы</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger>
<StepperIndicator>2</StepperIndicator>
<StepperTitle className="sr-only">Заметка</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={3}>
<StepperTrigger>
<StepperIndicator>3</StepperIndicator>
<StepperTitle className="sr-only">Подтверждение</StepperTitle>
</StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
<StepperContent value={1} className="flex flex-col gap-3">
<div className="flex items-center justify-between mb-1">
<p className="text-sm font-medium">Выберите серверы</p>
<div className="flex items-center gap-2">
<button type="button" onClick={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
className="text-xs text-primary hover:underline">Все</button>
<span className="text-border">·</span>
<button type="button" onClick={() => setManualServers(new Set())}
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
</div>
</div>
</div>
{liveServers.map((s) => {
const checked = manualServers.has(s.id)
return (
<button key={s.id} type="button" onClick={() => toggleManualServer(s.id)}
className={cn(
"flex items-center gap-3 rounded-lg border p-3 text-left transition-colors",
checked ? "border-primary/40 bg-primary/5" : "border-border hover:bg-muted/40"
)}>
<div className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
checked ? "bg-primary border-primary" : "border-border"
)}>
{checked && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l2.5 2.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{s.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs font-mono text-muted-foreground">{s.host}</span>
<StatusBadge status={s.status} />
{liveServers.map((s) => {
const checked = manualServers.has(s.id)
return (
<button key={s.id} type="button" onClick={() => toggleManualServer(s.id)}
className={cn(
"flex items-center gap-3 rounded-lg border p-3 text-left transition-colors",
checked ? "border-primary/40 bg-primary/5" : "border-border hover:bg-muted/40"
)}>
<div className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
checked ? "bg-primary border-primary" : "border-border"
)}>
{checked && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l2.5 2.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</div>
</div>
{s.status === "offline" && (
<span className="text-xs text-muted-foreground">недоступен</span>
)}
</button>
)
})}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Заметка</label>
<Input placeholder="Например: перед обновлением BGP"
value={manualNotes} onChange={(e) => setManualNotes(e.target.value)} />
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{s.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs font-mono text-muted-foreground">{s.host}</span>
<StatusBadge status={s.status} />
</div>
</div>
{s.status === "offline" && (
<span className="text-xs text-muted-foreground">недоступен</span>
)}
</button>
)
})}
</StepperContent>
<StepperContent value={2} className="flex flex-col gap-4">
<FormField label="Заметка">
<Input placeholder="Например: перед обновлением BGP"
value={manualNotes} onChange={(e) => setManualNotes(e.target.value)} />
</FormField>
</StepperContent>
<StepperContent value={3} className="flex flex-col gap-3 text-sm">
<p className="text-muted-foreground">
Будет создан бэкап для <strong className="text-foreground">{manualServers.size}</strong> серверов.
</p>
{manualNotes && (
<p className="text-muted-foreground">Заметка: {manualNotes}</p>
)}
</StepperContent>
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
<Button className="flex-1"
disabled={manualServers.size === 0 || backupJobId !== null}
onClick={handleManualBackup}>
Снять бэкап ({manualServers.size})
</Button>
{manualStep > 1 && (
<Button variant="outline" className="flex-1" onClick={() => setManualStep((s) => s - 1)}>
Назад
</Button>
)}
{manualStep < 3 ? (
<Button
className="flex-1"
disabled={manualStep === 1 && manualServers.size === 0}
onClick={() => setManualStep((s) => s + 1)}
>
Далее
</Button>
) : (
<Button className="flex-1"
disabled={manualServers.size === 0 || backupJobId !== null}
onClick={handleManualBackup}>
Снять бэкап ({manualServers.size})
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
<FileImportDialog
open={restoreOpen}
onOpenChange={setRestoreOpen}
title={restoreTarget ? `Восстановление: ${restoreTarget.filename}` : "Восстановление бэкапа"}
description="Выберите файл конфигурации для загрузки на роутер"
accept=".backup,.rsc,.zip"
onImport={async (files) => {
toast.success(`Файл ${files[0]?.name} подготовлен к восстановлению на ${restoreTarget?.server ?? "сервер"}`)
}}
/>
</div>
)
}
+75 -291
View File
@@ -2,6 +2,13 @@
import { Fragment, useState, useMemo, useEffect } from "react"
import { PageHeader } from "@/components/page-header"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
import type { Filter } from "@/components/reui/filters"
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
import { BGP_AS_NAMES } from "@/lib/bgp/types"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -17,49 +24,12 @@ import { useDataSource } from "@/lib/data-source"
// ─── types ────────────────────────────────────────────────────────────────────
type BgpState = "Established" | "Active" | "Idle" | "Connect" | "OpenSent" | "OpenConfirm"
type BgpType = "eBGP" | "iBGP"
type BgpAfi = "IPv4 Unicast" | "IPv6 Unicast" | "VPNv4 Unicast"
type BgpSession = BgpSessionRow
type BgpTab = "sessions" | "routers" | "analytics"
type StateFilter = "all" | BgpState
type TypeFilter = "all" | BgpType
interface BgpSession {
id: string
serverId: string
serverLabel: string
serverSite: string
peerIp: string
remoteAs: number
localAs: number
routerId: string
description: string
state: BgpState
type: BgpType
afi: BgpAfi
uptime: string | null
holdTime: number
keepalive: number
prefixesRx: number
prefixesTx: number
prefixesActive: number
inputMessages: number
outputMessages: number
capabilities: string[]
lastError: string | null
}
// ─── AS name lookup ───────────────────────────────────────────────────────────
const AS_NAMES: Record<number, string> = {
8359: "МТС / Tele2",
13238: "Яндекс",
12389: "Ростелеком",
24940: "Hetzner",
6777: "AMS-IX",
1299: "Telia",
65001: "iBGP internal",
}
const AS_NAMES = BGP_AS_NAMES
// ─── mock data ────────────────────────────────────────────────────────────────
const SESSIONS: BgpSession[] = [
@@ -240,13 +210,6 @@ function TypeBadge({ type }: { type: BgpType }) {
)
}
function CapChip({ cap }: { cap: string }) {
return (
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border bg-muted/60 text-muted-foreground border-border/60">
{cap}
</span>
)
}
function fmtNum(n: number) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
@@ -254,108 +217,6 @@ function fmtNum(n: number) {
return String(n)
}
function PrefixBar({ rx, tx, active }: { rx: number; tx: number; active: number }) {
const max = Math.max(rx, 1)
return (
<div className="flex flex-col gap-1.5 text-[10px] font-mono">
{[
{ label: "Получено", val: rx, color: "bg-[var(--chart-rx)]", w: rx / max },
{ label: "Активных", val: active, color: "bg-[var(--chart-1)]", w: active / max },
{ label: "Отправлено", val: tx, color: "bg-[var(--chart-tx)]", w: Math.min(tx / max, 1) },
].map(r => (
<div key={r.label} className="flex items-center gap-2">
<span className="w-20 text-muted-foreground shrink-0">{r.label}</span>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full", r.color)}
style={{ width: `${Math.max(r.w * 100, r.val > 0 ? 2 : 0)}%` }} />
</div>
<span className="w-14 text-right tabular-nums">{fmtNum(r.val)}</span>
</div>
))}
</div>
)
}
// ─── RSC snippet ──────────────────────────────────────────────────────────────
function rscSnippet(s: BgpSession) {
return `/routing bgp connection\nadd name=peer-as${s.remoteAs} remote.address=${s.peerIp}/32 \\\n remote.as=${s.remoteAs} local.role=${s.type === "eBGP" ? "ebgp" : "ibgp"} \\\n output.filter-chain=export-filter input.filter=import-filter \\\n routing-table=main`
}
// ─── session expanded row ─────────────────────────────────────────────────────
function SessionDetail({ s }: { s: BgpSession }) {
const [copied, setCopied] = useState(false)
function copy() {
navigator.clipboard.writeText(rscSnippet(s)).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 1800)
})
}
return (
<div className="px-4 pb-4 pt-2 bg-muted/20 border-t border-border/60">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
{[
{ label: "Router ID", value: s.routerId },
{ label: "Hold / KA", value: `${s.holdTime}s / ${s.keepalive}s` },
{ label: "AFI/SAFI", value: s.afi },
{ label: "Сообщения ↓/↑", value: `${fmtNum(s.inputMessages)} / ${fmtNum(s.outputMessages)}` },
].map(({ label, value }) => (
<div key={label}>
<p className="text-[10px] text-muted-foreground mb-0.5">{label}</p>
<p className="text-xs font-mono font-medium">{value}</p>
</div>
))}
</div>
{/* prefix bars */}
{s.state === "Established" && (
<div className="mb-4">
<p className="text-[10px] text-muted-foreground mb-2 uppercase tracking-wider font-semibold">Префиксы</p>
<PrefixBar rx={s.prefixesRx} tx={s.prefixesTx} active={s.prefixesActive} />
</div>
)}
{/* capabilities */}
{s.capabilities.length > 0 && (
<div className="mb-4">
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">Capabilities</p>
<div className="flex flex-wrap gap-1.5">
{s.capabilities.map(c => <CapChip key={c} cap={c} />)}
</div>
</div>
)}
{/* last error */}
{s.lastError && (
<div className="mb-4 flex items-center gap-2 rounded-md border border-red-500/20 bg-red-500/5 px-3 py-2">
<span className="size-1.5 rounded-full bg-red-500 shrink-0" />
<p className="text-xs font-mono text-red-500">{s.lastError}</p>
</div>
)}
{/* rsc export */}
<div className="mt-2">
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">RouterOS Export</p>
<div className="rounded-md bg-[#0a0f1a] border border-white/8 px-3 py-2.5 flex items-start justify-between gap-3">
<pre className="text-[10px] font-mono text-[#94a3b8] leading-relaxed whitespace-pre-wrap flex-1 min-w-0">
{rscSnippet(s)}
</pre>
<button onClick={copy}
className={cn(
"shrink-0 flex items-center gap-1 text-[10px] px-2 py-1 rounded border transition-colors",
copied
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-400"
: "border-white/10 text-white/40 hover:text-white/70 hover:border-white/20",
)}>
<ClipboardCopyIcon className="size-3" />
{copied ? "Скопировано" : "Копировать"}
</button>
</div>
</div>
</div>
)
}
// ─── backend mapping ──────────────────────────────────────────────────────────
interface BackendBgpSession {
@@ -397,158 +258,81 @@ function backendToFrontend(b: BackendBgpSession): BgpSession {
// ─── sessions tab ─────────────────────────────────────────────────────────────
const STATE_FILTERS: Array<{ value: StateFilter; label: string }> = [
{ value: "all", label: "Все" },
{ value: "Established", label: "Established" },
{ value: "Active", label: "Active" },
{ value: "Idle", label: "Idle" },
{ value: "OpenSent", label: "OpenSent" },
]
const BGP_FILTER_ACCESSORS = {
state: (s: BgpSession) => s.state,
type: (s: BgpSession) => s.type,
afi: (s: BgpSession) => s.afi,
}
function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
const [search, setSearch] = useState("")
const [search, setSearch] = useState("")
const [stateFilter, setStateFilter] = useState<StateFilter>("all")
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [expandedId, setExpandedId] = useState<string | null>(null)
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
const q = search.toLowerCase()
const filtered = useMemo(() => sessions.filter(s => {
if (stateFilter !== "all" && s.state !== stateFilter) return false
if (typeFilter !== "all" && s.type !== typeFilter) return false
if (q && !s.peerIp.includes(q) && !s.description.toLowerCase().includes(q)
&& !s.serverLabel.includes(q) && !String(s.remoteAs).includes(q)
&& !(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)) return false
return true
}), [sessions, q, stateFilter, typeFilter])
const filtered = useMemo(() => {
const base = sessions.filter((s) => {
if (stateFilter !== "all" && s.state !== stateFilter) return false
if (typeFilter !== "all" && s.type !== typeFilter) return false
if (
q &&
!s.peerIp.includes(q) &&
!s.description.toLowerCase().includes(q) &&
!s.serverLabel.includes(q) &&
!String(s.remoteAs).includes(q) &&
!(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)
) {
return false
}
return true
})
return applyReuiFilters(base, advancedFilters, BGP_FILTER_ACCESSORS)
}, [sessions, q, stateFilter, typeFilter, advancedFilters])
return (
<div className="flex flex-col gap-4">
{/* filter bar */}
<div className="flex items-center gap-2 flex-wrap">
{/* search */}
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none z-10" />
<Input
value={search} onChange={e => setSearch(e.target.value)}
placeholder="IP, AS, описание…"
className="h-8 pl-8 pr-8 w-52 text-xs"
/>
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground z-10">
<XIcon className="size-3" />
</button>
)}
</div>
{/* state filter */}
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
{STATE_FILTERS.map(f => (
<button key={f.value} onClick={() => setStateFilter(f.value)}
className={cn(
"px-2.5 py-1 text-[11px] rounded transition-colors whitespace-nowrap",
stateFilter === f.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}>
{f.label}
</button>
))}
</div>
{/* type filter */}
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
{(["all", "eBGP", "iBGP"] as const).map(t => (
<button key={t} onClick={() => setTypeFilter(t)}
className={cn(
"px-2.5 py-1 text-[11px] rounded transition-colors",
typeFilter === t ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}>
{t === "all" ? "Все типы" : t}
</button>
))}
</div>
<span className="text-xs text-muted-foreground ml-auto">
{filtered.length} из {sessions.length}
</span>
</div>
{/* table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/40">
<th className="w-8" />
{["Роутер", "Peer IP", "Remote AS", "Описание", "Тип", "Состояние", "Uptime", "Prefixes ↓", "Prefixes ↑"].map(h => (
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{filtered.map(s => {
const isOpen = expandedId === s.id
return (
<Fragment key={s.id}>
<tr
onClick={() => setExpandedId(isOpen ? null : s.id)}
className={cn(
"cursor-pointer transition-colors",
isOpen ? "bg-muted/30" : "hover:bg-muted/20",
)}>
<td className="pl-3 py-2.5">
{isOpen
? <ChevronDownIcon className="size-3.5 text-muted-foreground" />
: <ChevronRightIcon className="size-3.5 text-muted-foreground" />}
</td>
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{s.serverLabel}</td>
<td className="px-3 py-2.5 font-mono">{s.peerIp}</td>
<td className="px-3 py-2.5 font-mono">
<div className="flex items-center gap-1.5">
<span>AS{s.remoteAs}</span>
{AS_NAMES[s.remoteAs] && (
<span className="text-muted-foreground text-[10px]">{AS_NAMES[s.remoteAs]}</span>
)}
</div>
</td>
<td className="px-3 py-2.5 text-muted-foreground max-w-[180px] truncate">{s.description}</td>
<td className="px-3 py-2.5"><TypeBadge type={s.type} /></td>
<td className="px-3 py-2.5"><StateBadge state={s.state} /></td>
<td className="px-3 py-2.5 font-mono tabular-nums text-muted-foreground">
{s.uptime ?? "—"}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-right">
{s.prefixesRx > 0
? <span className="text-emerald-600 dark:text-emerald-400">{fmtNum(s.prefixesRx)}</span>
: <span className="text-muted-foreground"></span>}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-right">
{s.prefixesTx > 0
? <span className="text-[var(--chart-tx)]">{fmtNum(s.prefixesTx)}</span>
: <span className="text-muted-foreground"></span>}
</td>
</tr>
{isOpen && (
<tr>
<td colSpan={10} className="p-0">
<SessionDetail s={s} />
</td>
</tr>
)}
</Fragment>
)
})}
{filtered.length === 0 && (
<tr>
<td colSpan={10} className="px-4 py-8 text-center text-sm text-muted-foreground">
Нет сессий по заданным фильтрам
</td>
</tr>
)}
</tbody>
</table>
</div>
<DataPageToolbar
segmented={{
value: stateFilter,
onChange: setStateFilter,
options: [
{ value: "all", label: "Все", count: sessions.length },
{ value: "Established", label: "Established", count: sessions.filter((s) => s.state === "Established").length },
{ value: "Active", label: "Active", count: sessions.filter((s) => s.state === "Active").length },
{ value: "Idle", label: "Idle", count: sessions.filter((s) => s.state === "Idle").length },
{ value: "OpenSent", label: "OpenSent", count: sessions.filter((s) => s.state === "OpenSent").length },
],
}}
filters={advancedFilters}
onFiltersChange={setAdvancedFilters}
filterFields={BGP_FILTER_FIELDS}
search={search}
onSearchChange={setSearch}
searchPlaceholder="IP, AS, описание…"
countLabel={`${filtered.length} из ${sessions.length}`}
actions={
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
{(["all", "eBGP", "iBGP"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setTypeFilter(t)}
className={cn(
"px-2.5 py-1 text-[11px] rounded transition-colors",
typeFilter === t
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t === "all" ? "Все типы" : t}
</button>
))}
</div>
}
/>
<BgpSessionsDataGrid sessions={filtered} />
</Card>
</div>
)
+126 -92
View File
@@ -2,6 +2,8 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { FileImportDialog } from "@/components/file-import-dialog"
import { routerCertificates, servers as mockServers } from "@/lib/data"
import type { CertStatus, Server } from "@/lib/data"
import type { CertificateDto } from "@mmapp/contracts/certificates"
@@ -47,7 +49,19 @@ import {
ChevronDownIcon,
ChevronRightIcon,
RefreshCwIcon,
UploadIcon,
} from "lucide-react"
import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
const STATUS_CONFIG: Record<
CertStatus,
@@ -93,60 +107,6 @@ function daysLeftBar(days: number, total = 365): number {
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 {
return {
id: cert.id,
@@ -679,6 +639,7 @@ function CertPartIssueForm({
setIssueTrustWww,
issueTrustApi,
setIssueTrustApi,
step,
}: {
serverList: Server[]
issueServerId: string
@@ -693,12 +654,15 @@ function CertPartIssueForm({
setIssueTrustWww: (v: boolean) => void
issueTrustApi: boolean
setIssueTrustApi: (v: boolean) => void
step?: 1 | 2 | 3 | 4
}) {
const showAll = step == null
return (
<div className="flex flex-col gap-5">
{(showAll || step === 1) && (
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<Field label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
<FormField label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
<select
value={issueServerId}
onChange={(e) => setIssueServerId(e.target.value)}
@@ -713,41 +677,45 @@ function CertPartIssueForm({
</option>
))}
</select>
</Field>
<Field label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
</FormField>
<FormField label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
<Input
className="font-mono"
value={issueCertName}
onChange={(e) => setIssueCertName(e.target.value)}
placeholder="router-le"
/>
</Field>
</FormField>
</div>
)}
{(showAll || step === 2) && (
<div className="flex flex-col gap-4">
<SectionTitle>Домены</SectionTitle>
<Field label="Common Name" required hint="Основное имя в сертификате">
<FormField label="Common Name" required hint="Основное имя в сертификате">
<Input
className="font-mono"
value={issueCommonName}
onChange={(e) => setIssueCommonName(e.target.value)}
placeholder="vpn.example.com"
/>
</Field>
<Field label="SAN" hint="По одному имени в строке">
</FormField>
<FormField label="SAN" hint="По одному имени в строке">
<textarea
className="min-h-24 w-full rounded-lg border border-input bg-background px-2.5 py-2 text-sm font-mono text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
value={issueSans}
onChange={(e) => setIssueSans(e.target.value)}
placeholder="www.example.com"
/>
</Field>
</FormField>
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground mb-1">Let&apos;s Encrypt · DNS-01 (Cloudflare)</p>
<p>TXT-запись создаётся в Cloudflare, сертификат импортируется на выбранный RouterOS.</p>
</div>
</div>
)}
{(showAll || step === 3) && (
<div className="flex flex-col gap-4">
<SectionTitle>Импорт на RouterOS</SectionTitle>
<div className="flex items-center justify-between">
@@ -755,16 +723,29 @@ function CertPartIssueForm({
<p className="text-sm font-medium">Trust store · www</p>
<p className="text-xs text-muted-foreground">Веб-интерфейс и HTTPS-сервисы</p>
</div>
<Toggle checked={issueTrustWww} onChange={setIssueTrustWww} />
<FormToggle checked={issueTrustWww} onChange={setIssueTrustWww} />
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Trust store · api</p>
<p className="text-xs text-muted-foreground">REST API и управление</p>
</div>
<Toggle checked={issueTrustApi} onChange={setIssueTrustApi} />
<FormToggle checked={issueTrustApi} onChange={setIssueTrustApi} />
</div>
</div>
)}
{(showAll || step === 4) && (
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm">
<p className="font-medium mb-2">Проверьте параметры</p>
<ul className="text-xs text-muted-foreground space-y-1">
<li>Сервер: {serverList.find((s) => s.id === issueServerId)?.name ?? "—"}</li>
<li>Имя: {issueCertName || "—"}</li>
<li>CN: {issueCommonName || "—"}</li>
<li>Trust www: {issueTrustWww ? "да" : "нет"} · api: {issueTrustApi ? "да" : "нет"}</li>
</ul>
</div>
)}
</div>
)
}
@@ -783,6 +764,8 @@ export default function CertificatesPage() {
const [serverList, setServerList] = useState<Server[]>([])
const [issueOpen, setIssueOpen] = useState(false)
const [issueStep, setIssueStep] = useState(1)
const [importOpen, setImportOpen] = useState(false)
const [issueBusy, setIssueBusy] = useState(false)
const [issueServerId, setIssueServerId] = useState("")
const [issueCertName, setIssueCertName] = useState("")
@@ -1004,7 +987,11 @@ export default function CertificatesPage() {
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
Обновить
</Button>
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => setIssueOpen(true)}>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />
Импорт
</Button>
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => { setIssueStep(1); setIssueOpen(true) }}>
<PlusIcon className="size-4" />
Выпустить сертификат
</Button>
@@ -1090,7 +1077,7 @@ export default function CertificatesPage() {
</div>
</div>
<Sheet open={issueOpen} onOpenChange={setIssueOpen}>
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Выпуск сертификата</SheetTitle>
@@ -1099,40 +1086,87 @@ export default function CertificatesPage() {
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5">
<CertPartIssueForm
serverList={serverList}
issueServerId={issueServerId}
setIssueServerId={setIssueServerId}
issueCertName={issueCertName}
setIssueCertName={setIssueCertName}
issueCommonName={issueCommonName}
setIssueCommonName={setIssueCommonName}
issueSans={issueSans}
setIssueSans={setIssueSans}
issueTrustWww={issueTrustWww}
setIssueTrustWww={setIssueTrustWww}
issueTrustApi={issueTrustApi}
setIssueTrustApi={setIssueTrustApi}
/>
</div>
<Stepper value={issueStep} onValueChange={setIssueStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
{[
{ step: 1, title: "Основные" },
{ step: 2, title: "Домены" },
{ step: 3, title: "Импорт" },
{ step: 4, title: "Проверка" },
].map(({ step, title }, i, arr) => (
<StepperItem key={step} step={step}>
<StepperTrigger>
<StepperIndicator>{step}</StepperIndicator>
<StepperTitle className="sr-only">{title}</StepperTitle>
</StepperTrigger>
{i < arr.length - 1 && <StepperSeparator />}
</StepperItem>
))}
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
{[1, 2, 3, 4].map((s) => (
<StepperContent key={s} value={s}>
<CertPartIssueForm
step={s as 1 | 2 | 3 | 4}
serverList={serverList}
issueServerId={issueServerId}
setIssueServerId={setIssueServerId}
issueCertName={issueCertName}
setIssueCertName={setIssueCertName}
issueCommonName={issueCommonName}
setIssueCommonName={setIssueCommonName}
issueSans={issueSans}
setIssueSans={setIssueSans}
issueTrustWww={issueTrustWww}
setIssueTrustWww={setIssueTrustWww}
issueTrustApi={issueTrustApi}
setIssueTrustApi={setIssueTrustApi}
/>
</StepperContent>
))}
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={issueBusy} />}>
Отмена
</SheetClose>
<Button
className="flex-1"
disabled={!liveReady || issueBusy}
onClick={() => {
void handleIssue()
}}
>
{issueBusy ? "Выпуск…" : "Выпустить"}
</Button>
{issueStep > 1 && (
<Button variant="outline" className="flex-1" disabled={issueBusy} onClick={() => setIssueStep((s) => s - 1)}>
Назад
</Button>
)}
{issueStep < 4 ? (
<Button
className="flex-1"
disabled={issueStep === 1 && (!issueServerId || !issueCertName)}
onClick={() => setIssueStep((s) => s + 1)}
>
Далее
</Button>
) : (
<Button
className="flex-1"
disabled={!liveReady || issueBusy}
onClick={() => { void handleIssue() }}
>
{issueBusy ? "Выпуск…" : "Выпустить"}
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
<FileImportDialog
open={importOpen}
onOpenChange={setImportOpen}
title="Импорт сертификата"
description="Загрузите PEM, CRT или PKCS#12 для импорта на RouterOS"
accept=".pem,.crt,.cer,.p12,.pfx"
onImport={async (files) => {
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
}}
/>
</div>
)
}
+2 -1
View File
@@ -691,7 +691,8 @@ export default function DashboardPage() {
}
}
const loadingBlock = probesLoading && liveProbes === null
const liveDataPending = liveServersResolved === null || liveProbes === null
const loadingBlock = liveDataPending || (probesLoading && liveProbes === null)
const srvList = liveServersResolved ?? []
const totalSrv = srvList.length
const onlineSrv = srvList.filter((s) => s.status === "online").length
+284 -116
View File
@@ -3,6 +3,7 @@
import Link from "next/link"
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -24,7 +25,7 @@ import {
type SchedulerRunRowDto,
type UptimeSettingsDto,
} from "@/lib/scheduler-settings"
import { requestJson } from "@/shared/api/http-client"
import { requestJson, ApiClientError } from "@/shared/api/http-client"
import {
parseSchedulerRunSnapshot,
type AlertEngineRuleDiagSnapshot,
@@ -58,26 +59,25 @@ function makeApiFetch(backendUrl: string) {
}
}
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 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 extractApiError(reason: unknown): string {
if (reason instanceof ApiClientError) return reason.message
if (reason instanceof Error) return reason.message
return String(reason)
}
function readSettled<T>(result: PromiseSettledResult<T>): T | null {
return result.status === "fulfilled" ? result.value : null
}
function collectSettledErrors(results: PromiseSettledResult<unknown>[], labels: string[]): string[] {
const errors: string[] = []
for (let i = 0; i < results.length; i += 1) {
const result = results[i]
if (result.status === "rejected") {
errors.push(`${labels[i]}: ${extractApiError(result.reason)}`)
}
}
return errors
}
function fmtMs(ms: number): string {
@@ -729,9 +729,41 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
)
}
type SchedulerToggleOverrides = {
trafficEnabled?: boolean
serversApiEnabled?: boolean
resourcesEnabled?: boolean
pingEnabled?: boolean
speedEnabled?: boolean
internetPathEnabled?: boolean
certRenewEnabled?: boolean
}
function applySchedulerJobsToDrafts(
jobs: SchedulerJobStatusDto[],
setters: {
setDraftTrafficEnabled: (value: boolean) => void
setDraftServersApiEnabled: (value: boolean) => void
setDraftResourcesEnabled: (value: boolean) => void
setDraftPingEnabled: (value: boolean) => void
setDraftSpeedEnabled: (value: boolean) => void
setDraftInternetPathEnabled: (value: boolean) => void
setDraftCertRenewEnabled: (value: boolean) => void
},
) {
const byKey = Object.fromEntries(jobs.map((job) => [job.jobKey, job])) as Record<string, SchedulerJobStatusDto>
if (byKey.traffic) setters.setDraftTrafficEnabled(!!byKey.traffic.enabled)
if (byKey.servers_rest_ping) setters.setDraftServersApiEnabled(!!byKey.servers_rest_ping.enabled)
if (byKey.uptime_resources) setters.setDraftResourcesEnabled(!!byKey.uptime_resources.enabled)
if (byKey.uptime_ping) setters.setDraftPingEnabled(!!byKey.uptime_ping.enabled)
if (byKey.uptime_speed) setters.setDraftSpeedEnabled(!!byKey.uptime_speed.enabled)
if (byKey.internet_path) setters.setDraftInternetPathEnabled(!!byKey.internet_path.enabled)
if (byKey.certificates_renew) setters.setDraftCertRenewEnabled(!!byKey.certificates_renew.enabled)
}
export default function DataCollectionPage() {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = prefsHydrated && mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
@@ -786,7 +818,7 @@ export default function DataCollectionPage() {
runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number])
? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}`
: "?limit=80"
const [traffic, serversApi, uptime, internetPath, certRenew, runsRes] = await Promise.all([
const [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes] = await Promise.allSettled([
apiFetch<CollectorSettingsDto>("/api/traffic/settings"),
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings"),
apiFetch<UptimeSettingsDto>("/api/uptime/settings"),
@@ -794,28 +826,71 @@ export default function DataCollectionPage() {
apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings"),
apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`),
])
setTrafficCollector(traffic)
setServersApiCollector(serversApi)
setUptimeCollector(uptime)
setInternetPathCollector(internetPath)
setSchedulerRuns(runsRes.runs ?? [])
setTrafficIntervalDraft(String(traffic.intervalSec))
setTrafficRetentionDraft(String(traffic.retentionDays))
setUptimeResourceIntervalDraft(String(uptime.intervalSec ?? 300))
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? 15))
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
setUptimeRetentionDraft(String(uptime.retentionDays))
setDraftTrafficEnabled(!!traffic.enabled)
setDraftServersApiEnabled(!!serversApi.enabled)
setServersApiIntervalDraft(String(serversApi.intervalSec ?? 120))
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled))
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled))
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
setDraftInternetPathEnabled(!!internetPath.enabled)
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300))
setDraftCertRenewEnabled(!!certRenew.enabled)
setCertRenewIntervalDraft(String(certRenew.intervalSec ?? 21600))
setRenewBeforeDaysDraft(String(certRenew.renewBeforeDays ?? 30))
const loadErrors = collectSettledErrors(
[trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes],
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты", "журнал планировщика"],
)
if (loadErrors.length > 0) {
setCollectorError(loadErrors.join("; "))
}
const traffic = readSettled(trafficRes)
if (traffic) {
setTrafficCollector(traffic)
setTrafficIntervalDraft(String(traffic.intervalSec))
setTrafficRetentionDraft(String(traffic.retentionDays))
setDraftTrafficEnabled(!!traffic.enabled)
}
const serversApi = readSettled(serversApiRes)
if (serversApi) {
setServersApiCollector(serversApi)
setDraftServersApiEnabled(!!serversApi.enabled)
setServersApiIntervalDraft(String(serversApi.intervalSec ?? 120))
}
const uptime = readSettled(uptimeRes)
if (uptime) {
setUptimeCollector(uptime)
setUptimeResourceIntervalDraft(String(uptime.intervalSec ?? 300))
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? 15))
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
setUptimeRetentionDraft(String(uptime.retentionDays))
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled))
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled))
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
if (uptime.scheduler?.jobs?.length) {
applySchedulerJobsToDrafts(uptime.scheduler.jobs, {
setDraftTrafficEnabled,
setDraftServersApiEnabled,
setDraftResourcesEnabled,
setDraftPingEnabled,
setDraftSpeedEnabled,
setDraftInternetPathEnabled,
setDraftCertRenewEnabled,
})
}
}
const internetPath = readSettled(internetPathRes)
if (internetPath) {
setInternetPathCollector(internetPath)
setDraftInternetPathEnabled(!!internetPath.enabled)
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300))
}
const certRenew = readSettled(certRenewRes)
if (certRenew) {
setDraftCertRenewEnabled(!!certRenew.enabled)
setCertRenewIntervalDraft(String(certRenew.intervalSec ?? 21600))
setRenewBeforeDaysDraft(String(certRenew.renewBeforeDays ?? 30))
}
const runsPayload = readSettled(runsRes)
if (runsPayload) {
setSchedulerRuns(runsPayload.runs ?? [])
}
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
} finally {
@@ -823,6 +898,138 @@ export default function DataCollectionPage() {
}
}, [apiFetch, isLive, runFilterJobKey])
const persistSchedulerDrafts = useCallback(async (overrides: SchedulerToggleOverrides = {}) => {
const trafficEnabled = overrides.trafficEnabled ?? draftTrafficEnabled
const serversApiEnabled = overrides.serversApiEnabled ?? draftServersApiEnabled
const resourcesEnabled = overrides.resourcesEnabled ?? draftResourcesEnabled
const pingEnabled = overrides.pingEnabled ?? draftPingEnabled
const speedEnabled = overrides.speedEnabled ?? draftSpeedEnabled
const internetPathEnabled = overrides.internetPathEnabled ?? draftInternetPathEnabled
const certRenewEnabled = overrides.certRenewEnabled ?? draftCertRenewEnabled
const tInt = Math.max(5, Number.parseInt(trafficIntervalDraft, 10) || 30)
const tRet = Math.max(1, Number.parseInt(trafficRetentionDraft, 10) || 14)
const uRes = Math.max(5, Number.parseInt(uptimeResourceIntervalDraft, 10) || 300)
const uPing = Math.max(5, Number.parseInt(uptimeIntervalDraft, 10) || 15)
const uSpd = Math.max(10, Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60)
const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14)
const sApiInt = Math.max(10, Number.parseInt(serversApiIntervalDraft, 10) || 120)
const ipInt = Math.max(30, Number.parseInt(internetPathIntervalDraft, 10) || 300)
const certRenewInt = Math.max(300, Number.parseInt(certRenewIntervalDraft, 10) || 21600)
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30))
const saveResults = await Promise.allSettled([
apiFetch("/api/traffic/settings", {
method: "PUT",
body: JSON.stringify({
enabled: trafficEnabled,
intervalSec: tInt,
retentionDays: tRet,
}),
}),
apiFetch("/api/servers-api-ping/settings", {
method: "PUT",
body: JSON.stringify({
enabled: serversApiEnabled,
intervalSec: sApiInt,
}),
}),
apiFetch("/api/uptime/settings", {
method: "PUT",
body: JSON.stringify({
resourcesEnabled,
pingEnabled,
speedEnabled,
intervalSec: uRes,
probeIntervalSec: uPing,
speedIntervalSec: uSpd,
retentionDays: uRet,
}),
}),
apiFetch("/api/internet-path/settings", {
method: "PUT",
body: JSON.stringify({
enabled: internetPathEnabled,
intervalSec: ipInt,
}),
}),
apiFetch("/api/certificates/renew-settings", {
method: "PUT",
body: JSON.stringify({
enabled: certRenewEnabled,
intervalSec: certRenewInt,
renewBeforeDays,
}),
}),
])
const saveErrors = collectSettledErrors(
saveResults,
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты"],
)
if (saveErrors.length > 0) {
throw new Error(`Не все настройки сохранились: ${saveErrors.join("; ")}`)
}
}, [
apiFetch,
certRenewIntervalDraft,
draftCertRenewEnabled,
draftInternetPathEnabled,
draftPingEnabled,
draftResourcesEnabled,
draftServersApiEnabled,
draftSpeedEnabled,
draftTrafficEnabled,
internetPathIntervalDraft,
renewBeforeDaysDraft,
serversApiIntervalDraft,
trafficIntervalDraft,
trafficRetentionDraft,
uptimeIntervalDraft,
uptimeResourceIntervalDraft,
uptimeRetentionDraft,
uptimeSpeedIntervalDraft,
])
const handleJobEnabledChange = useCallback(async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number], nextEnabled: boolean) => {
const overrides: SchedulerToggleOverrides = {}
if (jobKey === "traffic") {
setDraftTrafficEnabled(nextEnabled)
overrides.trafficEnabled = nextEnabled
} else if (jobKey === "servers_rest_ping") {
setDraftServersApiEnabled(nextEnabled)
overrides.serversApiEnabled = nextEnabled
} else if (jobKey === "uptime_resources") {
setDraftResourcesEnabled(nextEnabled)
overrides.resourcesEnabled = nextEnabled
} else if (jobKey === "uptime_ping") {
setDraftPingEnabled(nextEnabled)
overrides.pingEnabled = nextEnabled
} else if (jobKey === "uptime_speed") {
setDraftSpeedEnabled(nextEnabled)
overrides.speedEnabled = nextEnabled
} else if (jobKey === "certificates_renew") {
setDraftCertRenewEnabled(nextEnabled)
overrides.certRenewEnabled = nextEnabled
} else if (jobKey === "internet_path") {
setDraftInternetPathEnabled(nextEnabled)
overrides.internetPathEnabled = nextEnabled
} else {
return
}
setCollectorError(null)
setSchedulerSaveBusy(true)
try {
await persistSchedulerDrafts(overrides)
await loadCollectors()
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
} finally {
setSchedulerSaveBusy(false)
}
}, [loadCollectors, persistSchedulerDrafts])
useEffect(() => {
if (!isLive) {
setTrafficCollector(null)
@@ -851,6 +1058,9 @@ export default function DataCollectionPage() {
}
const enabledJobsCount = useMemo(() => {
const jobs = uptimeCollector?.scheduler?.jobs
if (jobs?.length) return jobs.filter((job) => job.enabled).length
let n = draftTrafficEnabled ? 1 : 0
if (draftServersApiEnabled) n += 1
if (draftResourcesEnabled) n += 1
@@ -867,6 +1077,7 @@ export default function DataCollectionPage() {
draftServersApiEnabled,
draftSpeedEnabled,
draftTrafficEnabled,
uptimeCollector?.scheduler?.jobs,
])
const schedulerJobCount = SCHEDULER_JOB_KEYS.length
@@ -883,7 +1094,9 @@ export default function DataCollectionPage() {
{
label: "Включено задач",
value: `${enabledJobsCount} / ${schedulerJobCount}`,
sub: "По переключателям на этой странице (до сохранения)",
sub: uptimeCollector?.scheduler?.jobs?.length
? "По сохранённым задачам планировщика"
: "По переключателям на этой странице",
icon: <CalendarClockIcon className="size-4 text-muted-foreground" />,
},
{
@@ -950,7 +1163,18 @@ export default function DataCollectionPage() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5 max-w-[1100px] mx-auto w-full">
{!isLive && (
{!prefsHydrated && (
<Card>
<CardHeader>
<CardTitle className="text-base">Загрузка настроек подключения</CardTitle>
<CardDescription className="text-xs">
Читаем режим данных и адрес API из локальных настроек.
</CardDescription>
</CardHeader>
</Card>
)}
{prefsHydrated && !isLive && (
<Card>
<CardHeader>
<CardTitle className="text-base">Нужен live-режим</CardTitle>
@@ -995,7 +1219,7 @@ export default function DataCollectionPage() {
<CardHeader className="border-b border-border pb-4">
<CardTitle className="text-base">Планировщик сбора данных</CardTitle>
<CardDescription className="text-xs">
Интервалы и вкл/выкл по задачам. Сохранение отправляет настройки на бекенд и перезапускает таймеры.
Интервалы и вкл/выкл по задачам. Переключатель сразу сохраняет задачу на бекенде; кнопка ниже интервалы и срок хранения.
</CardDescription>
</CardHeader>
<CardContent className="px-0 pb-0">
@@ -1021,15 +1245,15 @@ export default function DataCollectionPage() {
? draftTrafficEnabled
: jobKey === "servers_rest_ping"
? draftServersApiEnabled
: jobKey === "uptime_resources"
: jobKey === "uptime_resources"
? draftResourcesEnabled
: jobKey === "uptime_ping"
? draftPingEnabled
: jobKey === "uptime_speed"
? draftSpeedEnabled
: jobKey === "certificates_renew"
? draftCertRenewEnabled
: draftInternetPathEnabled
: jobKey === "uptime_speed"
? draftSpeedEnabled
: jobKey === "certificates_renew"
? draftCertRenewEnabled
: draftInternetPathEnabled
const iv = fixedSchedule
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
: jobKey === "traffic"
@@ -1086,17 +1310,12 @@ export default function DataCollectionPage() {
</td>
<td className="px-3 py-3 text-center align-top">
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
<Toggle
<FormToggle
checked={en}
disabled={fixedSchedule || schedulerSaveBusy}
onChange={(v) => {
if (fixedSchedule) return
if (jobKey === "traffic") setDraftTrafficEnabled(v)
else if (jobKey === "servers_rest_ping") setDraftServersApiEnabled(v)
else if (jobKey === "uptime_resources") setDraftResourcesEnabled(v)
else if (jobKey === "uptime_ping") setDraftPingEnabled(v)
else if (jobKey === "uptime_speed") setDraftSpeedEnabled(v)
else if (jobKey === "certificates_renew") setDraftCertRenewEnabled(v)
else setDraftInternetPathEnabled(v)
if (fixedSchedule || schedulerSaveBusy) return
void handleJobEnabledChange(jobKey, v)
}}
/>
</span>
@@ -1218,58 +1437,7 @@ export default function DataCollectionPage() {
setSchedulerSaveBusy(true)
setCollectorError(null)
try {
const tInt = Math.max(5, Number.parseInt(trafficIntervalDraft, 10) || 30)
const tRet = Math.max(1, Number.parseInt(trafficRetentionDraft, 10) || 14)
const uRes = Math.max(5, Number.parseInt(uptimeResourceIntervalDraft, 10) || 300)
const uPing = Math.max(5, Number.parseInt(uptimeIntervalDraft, 10) || 15)
const uSpd = Math.max(10, Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60)
const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14)
const sApiInt = Math.max(10, Number.parseInt(serversApiIntervalDraft, 10) || 120)
const ipInt = Math.max(30, Number.parseInt(internetPathIntervalDraft, 10) || 300)
await apiFetch("/api/traffic/settings", {
method: "PUT",
body: JSON.stringify({
enabled: draftTrafficEnabled,
intervalSec: tInt,
retentionDays: tRet,
}),
})
await apiFetch("/api/servers-api-ping/settings", {
method: "PUT",
body: JSON.stringify({
enabled: draftServersApiEnabled,
intervalSec: sApiInt,
}),
})
await apiFetch("/api/uptime/settings", {
method: "PUT",
body: JSON.stringify({
resourcesEnabled: draftResourcesEnabled,
pingEnabled: draftPingEnabled,
speedEnabled: draftSpeedEnabled,
intervalSec: uRes,
probeIntervalSec: uPing,
speedIntervalSec: uSpd,
retentionDays: uRet,
}),
})
const certRenewInt = Math.max(300, Number.parseInt(certRenewIntervalDraft, 10) || 21600)
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30))
await apiFetch("/api/internet-path/settings", {
method: "PUT",
body: JSON.stringify({
enabled: draftInternetPathEnabled,
intervalSec: ipInt,
}),
})
await apiFetch("/api/certificates/renew-settings", {
method: "PUT",
body: JSON.stringify({
enabled: draftCertRenewEnabled,
intervalSec: certRenewInt,
renewBeforeDays,
}),
})
await persistSchedulerDrafts()
await loadCollectors()
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
+18 -2
View File
@@ -1,18 +1,21 @@
"use client"
import { useMemo } from "react"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { DataTable } from "@/components/data-table"
import { FileImportDialog } from "@/components/file-import-dialog"
import { domains as mockDomains } from "@/lib/data"
import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
export default function DomainsPage() {
const { mode } = useDataSource()
const { enabled, snapshot, loading, error } = useEvoBGP()
const [importOpen, setImportOpen] = useState(false)
const useEvoCatalog = mode === "live" && enabled
@@ -28,7 +31,9 @@ export default function DomainsPage() {
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
actions={
<>
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />Импорт
</Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
<Button size="sm"><PlusIcon className="size-4" />Добавить домен</Button>
</>
@@ -57,6 +62,7 @@ export default function DomainsPage() {
</div>
<DataTable
data={rows}
isLoading={useEvoCatalog && loading && !snapshot}
searchPlaceholder="Поиск по домену…"
searchKeys={["domain", "asn", "filter"]}
columns={[
@@ -109,6 +115,16 @@ export default function DomainsPage() {
/>
</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>
)
}
+18 -16
View File
@@ -2,6 +2,7 @@
import { useMemo, useState, useCallback, useEffect } from "react"
import { PageHeader } from "@/components/page-header"
import { EmptyState } from "@/components/empty-state"
import { StatusDot } from "@/components/status-dot"
import { Flag } from "@/components/flag"
import { Card } from "@/components/ui/card"
@@ -2062,22 +2063,23 @@ export default function FiltersPage() {
{currentRules.length === 0 ? (
/* empty state */
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
<NetworkIcon className="size-8 text-muted-foreground/20" />
<div>
<p className="text-sm font-medium text-muted-foreground">Нет правил фильтрации</p>
<p className="text-xs text-muted-foreground/60 mt-1">
{(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId))
? "Добавьте правило: BGP community → GRE-шлюз"
: "Сначала добавьте GRE-туннели для этого сервера"}
</p>
</div>
{(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId)) && (
<Button size="sm" onClick={openCreate}>
<PlusIcon className="size-4" />Добавить правило
</Button>
)}
</div>
<EmptyState
icon={<NetworkIcon className="size-4" />}
title="Нет правил фильтрации"
description={
(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId))
? "Добавьте правило: BGP community → GRE-шлюз"
: "Сначала добавьте GRE-туннели для этого сервера"
}
action={
(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId)) ? (
<Button size="sm" onClick={openCreate}>
<PlusIcon className="size-4" />Добавить правило
</Button>
) : undefined
}
className="py-20"
/>
) : filteredRules.length === 0 ? (
/* no search results */
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
+51 -89
View File
@@ -2,6 +2,7 @@
import { useEffect, useMemo, useRef, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { firewallRules, type FirewallRule } from "@/lib/data"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
@@ -341,22 +342,6 @@ function fmtHits(n: number): string {
return String(n)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button type="button" role="switch" aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
checked ? "bg-primary" : "bg-input",
)}>
<span className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0",
)} />
</button>
)
}
function ActionBadge({ action }: { action: string }) {
const cls = ACTION_STYLES[action] ?? "bg-muted text-muted-foreground border-border"
return (
@@ -375,29 +360,6 @@ function ChainBadge({ chain }: { chain: string }) {
)
}
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 }: {
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
}) {
@@ -507,67 +469,67 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
<div className="flex flex-col gap-4">
<SectionTitle>Цепочка и действие</SectionTitle>
<div className="grid grid-cols-2 gap-3">
<Field label="Цепочка" required>
<FormField label="Цепочка" required>
<NativeSelect value={form.chain} onChange={(v) => set("chain", v)}>
{chainsForGroup.map((c) => <option key={c} value={c}>{c}</option>)}
</NativeSelect>
</Field>
<Field label="Действие" required>
</FormField>
<FormField label="Действие" required>
<NativeSelect value={form.action} onChange={(v) => set("action", v)}>
{actions.map((a) => <option key={a} value={a}>{a}</option>)}
</NativeSelect>
</Field>
</FormField>
</div>
</div>
{/* Matching */}
<div className="flex flex-col gap-4">
<SectionTitle>Условие совпадения</SectionTitle>
<Field label="Протокол">
<FormField label="Протокол">
<NativeSelect value={form.proto} onChange={(v) => set("proto", v)}>
{["all","tcp","udp","icmp","gre","esp","ah","ipencap","ospf"].map((p) =>
<option key={p} value={p}>{p}</option>
)}
</NativeSelect>
</Field>
</FormField>
<div className="grid grid-cols-2 gap-3">
<Field label="Src-address / Address-list" hint="IP, CIDR или имя address-list">
<FormField label="Src-address / Address-list" hint="IP, CIDR или имя address-list">
<Input className="font-mono h-8" placeholder="10.0.0.0/8"
value={form.srcAddrList} onChange={(e) => set("srcAddrList", e.target.value)} />
</Field>
<Field label="Dst-address / Address-list">
</FormField>
<FormField label="Dst-address / Address-list">
<Input className="font-mono h-8" placeholder="0.0.0.0/0"
value={form.dstAddrList} onChange={(e) => set("dstAddrList", e.target.value)} />
</Field>
</FormField>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Src-port" hint="TCP/UDP, напр. 1024-65535">
<FormField label="Src-port" hint="TCP/UDP, напр. 1024-65535">
<Input className="font-mono h-8" placeholder="—"
value={form.srcPort} onChange={(e) => set("srcPort", e.target.value)} />
</Field>
<Field label="Dst-port">
</FormField>
<FormField label="Dst-port">
<Input className="font-mono h-8" placeholder="443"
value={form.dstPort} onChange={(e) => set("dstPort", e.target.value)} />
</Field>
</FormField>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="In-interface" hint="Входящий интерфейс">
<FormField label="In-interface" hint="Входящий интерфейс">
<Input className="font-mono h-8" placeholder="wan-msk"
value={form.inIface} onChange={(e) => set("inIface", e.target.value)} />
</Field>
<Field label="Out-interface">
</FormField>
<FormField label="Out-interface">
<Input className="font-mono h-8" placeholder="lan"
value={form.outIface} onChange={(e) => set("outIface", e.target.value)} />
</Field>
</FormField>
</div>
<Field label="Connection-state" hint="Через запятую: new, established, related, invalid">
<FormField label="Connection-state" hint="Через запятую: new, established, related, invalid">
<Input className="font-mono h-8" placeholder="new,established"
value={form.connState} onChange={(e) => set("connState", e.target.value)} />
</Field>
</FormField>
</div>
{/* Log + Comment */}
@@ -578,18 +540,18 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
<p className="text-sm font-medium">Log</p>
<p className="text-xs text-muted-foreground">Записывать совпадения в системный лог</p>
</div>
<Toggle checked={form.log} onChange={(v) => set("log", v)} />
<FormToggle checked={form.log} onChange={(v) => set("log", v)} />
</div>
{form.log && (
<Field label="Log-prefix" hint="Метка в логе, например FW-DROP">
<FormField label="Log-prefix" hint="Метка в логе, например FW-DROP">
<Input className="font-mono h-8" placeholder="FW-RULE"
value={form.logPrefix} onChange={(e) => set("logPrefix", e.target.value)} />
</Field>
</FormField>
)}
<Field label="Комментарий">
<FormField label="Комментарий">
<Input className="h-8" placeholder="Описание правила"
value={form.comment} onChange={(e) => set("comment", e.target.value)} />
</Field>
</FormField>
</div>
{/* Enabled */}
@@ -598,7 +560,7 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
<p className="text-sm font-medium">Правило включено</p>
<p className="text-xs text-muted-foreground">Отключённые правила сохраняются, но не применяются</p>
</div>
<Toggle checked={form.enabled} onChange={(v) => set("enabled", v)} />
<FormToggle checked={form.enabled} onChange={(v) => set("enabled", v)} />
</div>
{/* CLI preview */}
@@ -1039,14 +1001,14 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
<div className="flex flex-col gap-3">
<SectionTitle>Название</SectionTitle>
<div className="grid grid-cols-2 gap-3">
<Field label="Название сценария" required>
<FormField label="Название сценария" required>
<Input className="h-8" placeholder="Блокировка Tor Exit"
value={name} onChange={e => setName(e.target.value)} />
</Field>
<Field label="Описание">
</FormField>
<FormField label="Описание">
<Input className="h-8" placeholder="Краткое описание"
value={desc} onChange={e => setDesc(e.target.value)} />
</Field>
</FormField>
</div>
</div>
@@ -1054,7 +1016,7 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
<div className="flex flex-col gap-3">
<SectionTitle>Тестовый пакет по умолчанию</SectionTitle>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<Field label="Направление / цепочка">
<FormField label="Направление / цепочка">
<NativeSelect value={pkt.chain} onChange={v => setP("chain", v)}>
<optgroup label="Полный маршрут">
<option value="forward">forward транзит</option>
@@ -1067,40 +1029,40 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
.map(c => <option key={c} value={c}>{c}</option>)}
</optgroup>
</NativeSelect>
</Field>
<Field label="Протокол">
</FormField>
<FormField label="Протокол">
<NativeSelect value={pkt.proto} onChange={v => setP("proto", v)}>
{PROTO_OPTS.map(p => <option key={p} value={p}>{p}</option>)}
</NativeSelect>
</Field>
<Field label="Conn-state">
</FormField>
<FormField label="Conn-state">
<Input className="font-mono h-8" value={pkt.connState}
placeholder="new" onChange={e => setP("connState", e.target.value)} />
</Field>
<Field label="Src IP">
</FormField>
<FormField label="Src IP">
<Input className="font-mono h-8" value={pkt.srcAddr}
onChange={e => setP("srcAddr", e.target.value)} />
</Field>
<Field label="Dst IP">
</FormField>
<FormField label="Dst IP">
<Input className="font-mono h-8" value={pkt.dstAddr}
onChange={e => setP("dstAddr", e.target.value)} />
</Field>
<Field label="Dst Port">
</FormField>
<FormField label="Dst Port">
<Input className="font-mono h-8" value={pkt.dstPort}
placeholder="443" onChange={e => setP("dstPort", e.target.value)} />
</Field>
<Field label="In-interface">
</FormField>
<FormField label="In-interface">
<Input className="font-mono h-8" value={pkt.inIface}
placeholder="lan" onChange={e => setP("inIface", e.target.value)} />
</Field>
<Field label="Out-interface">
</FormField>
<FormField label="Out-interface">
<Input className="font-mono h-8" value={pkt.outIface}
placeholder="wan-msk" onChange={e => setP("outIface", e.target.value)} />
</Field>
<Field label="Dst addr-list">
</FormField>
<FormField label="Dst addr-list">
<Input className="font-mono h-8" value={pkt.dstAddrList}
placeholder="" onChange={e => setP("dstAddrList", e.target.value)} />
</Field>
</FormField>
</div>
</div>
@@ -1170,7 +1132,7 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Toggle checked={addForm.enabled} onChange={v => setAF("enabled", v)} />
<FormToggle checked={addForm.enabled} onChange={v => setAF("enabled", v)} />
<span className="text-xs text-muted-foreground">Включено</span>
</div>
<Button size="sm" onClick={addRule}><PlusIcon className="size-4" />Добавить</Button>
@@ -1783,7 +1745,7 @@ function RulesTable({ rules, onToggle, onEdit }: {
</span>
</td>
<td className="px-4 py-2.5">
<Toggle checked={r.enabled} onChange={() => onToggle(r.id)} />
<FormToggle checked={r.enabled} onChange={() => onToggle(r.id)} />
</td>
<td className="px-3 py-2.5">
<DropdownMenu>
+47 -98
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data"
import type { GrePool, GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion, Server } from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
@@ -138,58 +139,6 @@ function IpsecBadge({ secured }: { secured: boolean }) {
)
}
function Field({ label, hint, required, children }: {
label: string; hint?: string; required?: boolean; children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors ${checked ? "bg-primary" : "bg-input"}`}
>
<span className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-4" : "translate-x-0"}`} />
</button>
)
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 py-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
function SegmentedControl<T extends string>({ value, onChange, options }: {
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
}) {
return (
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
{options.map((o) => (
<button key={o.value} type="button" onClick={() => onChange(o.value)}
className={`px-3 py-1 text-sm rounded transition-colors ${value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
{o.label}
</button>
))}
</div>
)
}
// ─── Live API (как на /filters) ─────────────────────────────────────────────
interface BackendServer {
@@ -838,51 +787,51 @@ export default function GrePage() {
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<Field label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
<FormField label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
<Input className="font-mono" placeholder="gre-msk-spb" value={tForm.name} onChange={(e) => setT("name", e.target.value)} />
</Field>
<Field label="Сервер (MikroTik)" required>
</FormField>
<FormField label="Сервер (MikroTik)" required>
<select value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="" disabled>Выбрать сервер</option>
{displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
</select>
</Field>
<Field label="Комментарий">
</FormField>
<FormField label="Комментарий">
<Input placeholder="Описание туннеля" value={tForm.comment} onChange={(e) => setT("comment", e.target.value)} />
</Field>
</FormField>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Включён</span>
<Toggle checked={tForm.enabled} onChange={(v) => setT("enabled", v)} />
<FormToggle checked={tForm.enabled} onChange={(v) => setT("enabled", v)} />
</div>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Эндпоинты</SectionTitle>
<Field label="Локальный адрес" hint="Оставьте пустым или 0.0.0.0 для автоопределения">
<FormField label="Локальный адрес" hint="Оставьте пустым или 0.0.0.0 для автоопределения">
<Input className="font-mono" placeholder="0.0.0.0" value={tForm.localAddress} onChange={(e) => setT("localAddress", e.target.value)} />
</Field>
<Field label="Удалённый адрес" required hint="Внешний IP удалённого MikroTik">
</FormField>
<FormField label="Удалённый адрес" required hint="Внешний IP удалённого MikroTik">
<Input className="font-mono" placeholder="203.0.113.1" value={tForm.remoteAddress} onChange={(e) => setT("remoteAddress", e.target.value)} />
</Field>
</FormField>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Внутренний IP</SectionTitle>
<Field label="IP-пул" required hint="Из какого пула выделяется /30-блок">
<FormField label="IP-пул" required hint="Из какого пула выделяется /30-блок">
<select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="" disabled>Выбрать пул</option>
{displayPools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) свободно {p.total - p.allocated} блоков</option>)}
</select>
</Field>
</FormField>
<div className="grid grid-cols-2 gap-3">
<Field label="Локальный IP" required hint="/ip address на этом конце">
<FormField label="Локальный IP" required hint="/ip address на этом конце">
<Input className="font-mono" placeholder="10.200.0.1/30" value={tForm.localInnerIp} onChange={(e) => setT("localInnerIp", e.target.value)} />
</Field>
<Field label="Удалённый IP" required hint="/ip address на другом конце">
</FormField>
<FormField label="Удалённый IP" required hint="/ip address на другом конце">
<Input className="font-mono" placeholder="10.200.0.2/30" value={tForm.remoteInnerIp} onChange={(e) => setT("remoteInnerIp", e.target.value)} />
</Field>
</FormField>
</div>
</div>
@@ -893,12 +842,12 @@ export default function GrePage() {
<p className="text-sm font-medium">Включить IPsec</p>
<p className="text-xs text-muted-foreground">RouterOS автоматически создаст peer, policy и proposal</p>
</div>
<Toggle checked={tForm.ipsecEnabled} onChange={(v) => setT("ipsecEnabled", v)} />
<FormToggle checked={tForm.ipsecEnabled} onChange={(v) => setT("ipsecEnabled", v)} />
</div>
{tForm.ipsecEnabled && (
<div className="flex flex-col gap-4 pl-4 border-l-2 border-emerald-500/30">
<Field label="Пароль (PSK)" required hint="ipsec-secret — pre-shared key для автоматического IKE">
<FormField label="Пароль (PSK)" required hint="ipsec-secret — pre-shared key для автоматического IKE">
<div className="relative">
<Input type={tForm.ipsecShowSecret ? "text" : "password"} className="font-mono pr-9"
placeholder="Минимум 8 символов" value={tForm.ipsecSecret} onChange={(e) => setT("ipsecSecret", e.target.value)} />
@@ -907,38 +856,38 @@ export default function GrePage() {
{tForm.ipsecShowSecret ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
</button>
</div>
</Field>
<Field label="IKE-версия">
</FormField>
<FormField label="IKE-версия">
<SegmentedControl value={tForm.ipsecIkeVersion} onChange={(v) => setT("ipsecIkeVersion", v)}
options={[{ value: "ikev1", label: "IKEv1" }, { value: "ikev2", label: "IKEv2 (рек.)" }]} />
</Field>
</FormField>
<div className="grid grid-cols-2 gap-3">
<Field label="Шифрование">
<FormField label="Шифрование">
<select value={tForm.ipsecEncAlg} onChange={(e) => setT("ipsecEncAlg", e.target.value as IpsecEncAlg)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
{(Object.entries(ENC_LABELS) as [IpsecEncAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
<Field label="Хеш-алгоритм">
</FormField>
<FormField label="Хеш-алгоритм">
<select value={tForm.ipsecAuthAlg} onChange={(e) => setT("ipsecAuthAlg", e.target.value as IpsecAuthAlg)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
{(Object.entries(AUTH_LABELS) as [IpsecAuthAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
</FormField>
</div>
<Field label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
<FormField label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
<select value={tForm.ipsecDhGroup} onChange={(e) => setT("ipsecDhGroup", e.target.value as IpsecDhGroup)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
{(Object.entries(DH_LABELS) as [IpsecDhGroup, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
</FormField>
<div className="grid grid-cols-2 gap-3">
<Field label="Срок жизни SA" hint="Формат: 1d 00:00:00">
<FormField label="Срок жизни SA" hint="Формат: 1d 00:00:00">
<Input className="font-mono" value={tForm.ipsecLifetime} onChange={(e) => setT("ipsecLifetime", e.target.value)} />
</Field>
</FormField>
<div className="flex items-center justify-between pt-6">
<span className="text-sm font-medium">PFS</span>
<Toggle checked={tForm.ipsecPfs} onChange={(v) => setT("ipsecPfs", v)} />
<FormToggle checked={tForm.ipsecPfs} onChange={(v) => setT("ipsecPfs", v)} />
</div>
</div>
</div>
@@ -955,23 +904,23 @@ export default function GrePage() {
{tForm.showAdvanced && (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-3 gap-3">
<Field label="MTU" hint="По умолч. 1476">
<FormField label="MTU" hint="По умолч. 1476">
<Input type="number" className="font-mono" value={tForm.mtu} onChange={(e) => setT("mtu", Number(e.target.value))} />
</Field>
<Field label="Keepalive, с" hint="0 = откл.">
</FormField>
<FormField label="Keepalive, с" hint="0 = откл.">
<Input type="number" className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
</Field>
<Field label="Попытки">
</FormField>
<FormField label="Попытки">
<Input type="number" className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} />
</Field>
</FormField>
</div>
<Field label="DSCP">
<FormField label="DSCP">
<select value={tForm.dscp} onChange={(e) => setT("dscp", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="inherit">inherit</option>
{Array.from({ length: 64 }, (_, i) => <option key={i} value={String(i)}>{i}</option>)}
</select>
</Field>
</FormField>
{[
{ key: "clampTcpMss" as const, label: "Clamp TCP MSS", desc: "Ограничить MSS до MTU туннеля" },
{ key: "allowFastPath" as const, label: "Allow Fast Path", desc: "Аппаратное ускорение трафика" },
@@ -981,7 +930,7 @@ export default function GrePage() {
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">{desc}</p>
</div>
<Toggle checked={tForm[key] as boolean} onChange={(v) => setT(key, v)} />
<FormToggle checked={tForm[key] as boolean} onChange={(v) => setT(key, v)} />
</div>
))}
</div>
@@ -1006,18 +955,18 @@ export default function GrePage() {
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Параметры пула</SectionTitle>
<Field label="Имя пула" required hint="Например pool-gre-office или pool-gre-dc2">
<FormField label="Имя пула" required hint="Например pool-gre-office или pool-gre-dc2">
<Input className="font-mono" placeholder="pool-gre-core" value={pForm.name}
onChange={(e) => setPForm((f) => ({ ...f, name: e.target.value }))} />
</Field>
<Field label="Диапазон CIDR" required hint="Блок, из которого будут нарезаться /30 на каждый туннель">
</FormField>
<FormField label="Диапазон CIDR" required hint="Блок, из которого будут нарезаться /30 на каждый туннель">
<Input className="font-mono" placeholder="10.200.0.0/24" value={pForm.cidr}
onChange={(e) => setPForm((f) => ({ ...f, cidr: e.target.value }))} />
</Field>
<Field label="Назначение / Комментарий">
</FormField>
<FormField label="Назначение / Комментарий">
<Input placeholder="Ядровые межузловые туннели" value={pForm.comment}
onChange={(e) => setPForm((f) => ({ ...f, comment: e.target.value }))} />
</Field>
</FormField>
{pForm.cidr && /\/\d+$/.test(pForm.cidr) && (() => {
const prefix = parseInt(pForm.cidr.split("/")[1] ?? "0")
const blocks = prefix <= 30 ? Math.pow(2, 30 - prefix) : 0
+18 -3
View File
@@ -1,20 +1,22 @@
"use client"
import { useMemo } from "react"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { DataTable } from "@/components/data-table"
import { FileImportDialog } from "@/components/file-import-dialog"
import { ipRanges as mockIpRanges } from "@/lib/data"
import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
export default function IpRangesPage() {
const { mode } = useDataSource()
const { enabled, snapshot, loading, error } = useEvoBGP()
const [importOpen, setImportOpen] = useState(false)
/** При включённом EvoBGP в live локальные моки не показываем — только каталог API (или пусто при загрузке/ошибке). */
const useEvoCatalog = mode === "live" && enabled
const rows = useMemo(() => {
@@ -29,7 +31,9 @@ export default function IpRangesPage() {
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
actions={
<>
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />Импорт
</Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
<Button size="sm"><PlusIcon className="size-4" />Добавить диапазон</Button>
</>
@@ -58,6 +62,7 @@ export default function IpRangesPage() {
</div>
<DataTable
data={rows}
isLoading={useEvoCatalog && loading && !snapshot}
searchPlaceholder="Поиск по CIDR, ASN…"
searchKeys={["cidr", "asn", "country", "filter"]}
columns={[
@@ -108,6 +113,16 @@ export default function IpRangesPage() {
/>
</div>
</div>
<FileImportDialog
open={importOpen}
onOpenChange={setImportOpen}
title="Импорт IP-диапазонов"
description="Загрузите CSV или JSON со списком CIDR-блоков"
accept=".csv,.json,text/csv,application/json"
onImport={async (files) => {
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
}}
/>
</div>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { Skeleton } from "@/components/ui/skeleton"
export default function MainLoading() {
return (
<div className="flex flex-col gap-5 p-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
<Skeleton className="h-10 w-full max-w-md rounded-lg" />
<Skeleton className="h-96 w-full rounded-xl" />
</div>
)
}
+3 -13
View File
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -353,17 +354,6 @@ function NativeSelect({ value, onChange, children, className }: {
)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button onClick={() => onChange(!checked)}
className={cn("relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
checked ? "bg-primary" : "bg-muted-foreground/30")}>
<span className={cn("inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform",
checked ? "translate-x-4" : "translate-x-0.5")} />
</button>
)
}
function OptionLabel({ children }: { children: React.ReactNode }) {
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
}
@@ -593,7 +583,7 @@ function ScheduleTab({
"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}
<FormToggle checked={rule.enabled}
onChange={v => setRules(p => p.map(r => r.id === rule.id ? { ...r, enabled: v } : r))} />
<code className="font-mono text-xs truncate">{tun?.name ?? rule.tunnelId}</code>
<span className="text-xs text-muted-foreground truncate">{src?.name ?? rule.srcId}</span>
@@ -1115,7 +1105,7 @@ export default function ProbesPage() {
Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.
</p>
</div>
<Toggle checked={traceUseDns} onChange={setTraceUseDns} />
<FormToggle checked={traceUseDns} onChange={setTraceUseDns} />
</div>
</>
)}
+16 -41
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { FormField, SectionTitle } from "@/components/form-kit"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -148,32 +149,6 @@ const emptyForm = (): RouteForm => ({
endpoints: [newEndpoint()],
})
function Field({ label, hint, required, children }: {
label: string
hint?: string
required?: boolean
children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 py-0.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
function RouteGroupRows({
group, expanded, onToggle, onEdit, onDelete,
}: {
@@ -370,9 +345,9 @@ function RouteSheet({
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<Field label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24">
<FormField label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24">
<Input className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} />
</Field>
</FormField>
</div>
<div className="flex flex-col gap-4">
@@ -396,26 +371,26 @@ function RouteSheet({
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
<Field label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
<FormField label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
<Input className="font-mono h-9" placeholder="1.2.3.4%GW-NAME" value={ep.gateway} onChange={(e) => setEp(ep.id, "gateway", e.target.value)} />
</Field>
</FormField>
<div className="grid grid-cols-2 gap-2">
<Field label="Distance (приоритет)">
<FormField label="Distance (приоритет)">
<Input type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value) || 1)} />
</Field>
<Field label="Check Gateway">
</FormField>
<FormField label="Check Gateway">
<Input className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
</Field>
</FormField>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="Scope">
<FormField label="Scope">
<Input type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
</Field>
<Field label="T.Scope">
</FormField>
<FormField label="T.Scope">
<Input type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
</Field>
</FormField>
</div>
<div className="flex flex-col gap-1.5 max-h-[180px] overflow-y-auto overflow-x-hidden pr-1">
@@ -467,10 +442,10 @@ function RouteSheet({
<div className="flex flex-col gap-4">
<SectionTitle>Параметры</SectionTitle>
<Field label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></Field>
<Field label="Комментарий">
<FormField label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></FormField>
<FormField label="Комментарий">
<Input className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
</Field>
</FormField>
</div>
{error && <div className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon className="size-4 shrink-0" />{error}</div>}
</div>
+2 -12
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
import Link from "next/link"
import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -245,17 +246,6 @@ function LossChip({ loss }: { loss: number }) {
)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button type="button" onClick={() => onChange(!checked)}
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
checked ? "bg-primary" : "bg-input")}>
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0")} />
</button>
)
}
function NInput({ value, onChange, min, max }: { value: number; onChange: (v: number) => void; min?: number; max?: number }) {
return (
<Input type="number" value={value} min={min} max={max}
@@ -1167,7 +1157,7 @@ export default function RouteOptimizerPage() {
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Автоприменение</p>
<div className="flex items-center justify-between">
<span className="text-sm">Применять автоматически</span>
<Toggle checked={settings.autoApply} onChange={v => set("autoApply", v)} />
<FormToggle checked={settings.autoApply} onChange={v => set("autoApply", v)} />
</div>
{settings.autoApply && (
<>
+155 -468
View File
@@ -1,8 +1,13 @@
"use client"
import { Fragment, useEffect, useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { StatusBadge } from "@/components/status-badge"
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"
import type { Filter } from "@/components/reui/filters"
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
import { SERVER_FILTER_ACCESSORS, SERVER_FILTER_FIELDS } from "@/lib/data-filters/server-filter-fields"
import { servers as initialServers } from "@/lib/data"
import type { ServerType, Server, WanUplink } from "@/lib/data"
import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers"
@@ -30,56 +35,25 @@ import {
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
} from "@/components/ui/dropdown-menu"
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
import {
SearchIcon, RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
MoreHorizontalIcon, EyeIcon, EyeOffIcon,
RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
EyeIcon, EyeOffIcon,
ChevronRightIcon, ChevronDownIcon,
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
ShieldIcon, WifiIcon, PencilIcon, PowerIcon, Trash2Icon, ExternalLinkIcon,
ShieldIcon, WifiIcon,
HomeIcon, ServerIcon, NetworkIcon,
} from "lucide-react"
// ─── RouterOS version utilities ───────────────────────────────────────────────
/** Numeric version score: "7.20.1 (stable)" → 720, "7.14.2" → 714, "7.9" → 709 */
function rosVer(os: string): number {
const m = os.match(/(\d+)\.(\d+)/)
if (!m) return 0
return parseInt(m[1], 10) * 100 + parseInt(m[2], 10)
}
interface RosFeature { name: string; minVer: number; label: string; desc: string }
const ROS_FEATURES: RosFeature[] = [
{ name: "WireGuard", minVer: 701, label: "7.1+", desc: "WireGuard VPN туннели" },
{ name: "Container", minVer: 704, label: "7.4+", desc: "Docker-совместимые контейнеры" },
{ name: "BFD", minVer: 705, label: "7.5+", desc: "Bidirectional Forwarding Detection" },
{ name: "Large Communities", minVer: 707, label: "7.7+", desc: "BGP Large Communities (RFC 8092)" },
{ name: "VXLAN", minVer: 710, label: "7.10+", desc: "VXLAN overlay туннели" },
{ name: "RPKI", minVer: 713, label: "7.13+", desc: "Route Origin Validation" },
{ name: "BGP Flowspec", minVer: 714, label: "7.14+", desc: "BGP Flow Spec (RFC 8955)" },
{ name: "IPv6 Firewall", minVer: 715, label: "7.15+", desc: "Расширенный IPv6 Firewall" },
{ name: "REST API v2", minVer: 716, label: "7.16+", desc: "Обновлённый REST API" },
{ name: "VRF Enhanced", minVer: 717, label: "7.17+", desc: "Расширенная поддержка VRF" },
]
function RosBadge({ os }: { os: string }) {
const v = rosVer(os)
const cls = v >= 715
? "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/20"
: v >= 710
? "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/20"
: "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/20"
return (
<span className={cn("text-xs font-mono border rounded px-2 py-0.5", cls)}>
{os}
</span>
)
}
// ─── Countries ───────────────────────────────────────────────────────────────
const COUNTRIES = [
@@ -97,90 +71,8 @@ const COUNTRIES = [
{ code: "NO", label: "Норвегия" },
]
// ─── Type config ─────────────────────────────────────────────────────────────
const TYPE_LABELS: Record<ServerType, string> = {
"jump-host": "JumpHost",
"exit-node": "Exit Node",
"home-router": "Home Router",
}
const TYPE_STYLES: Record<ServerType, string> = {
"jump-host": "bg-violet-500/10 text-violet-400 border-violet-500/20",
"exit-node": "bg-sky-500/10 text-sky-400 border-sky-500/20",
"home-router": "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
}
const TYPE_ICONS: Record<ServerType, React.ReactNode> = {
"jump-host": <ServerIcon className="size-3 mr-1" />,
"exit-node": <NetworkIcon className="size-3 mr-1" />,
"home-router": <HomeIcon className="size-3 mr-1" />,
}
function TypeBadge({ type }: { type: ServerType }) {
return (
<span className={cn(
"inline-flex items-center text-xs font-medium border rounded px-2 py-0.5",
TYPE_STYLES[type],
)}>
{TYPE_ICONS[type]}{TYPE_LABELS[type]}
</span>
)
}
// ─── Shared small components ──────────────────────────────────────────────────
function Field({ label, hint, required, children }: {
label: string; hint?: string; required?: boolean; children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button type="button" role="switch" aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
checked ? "bg-primary" : "bg-input")}>
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0")} />
</button>
)
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 py-0.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
function SegmentedControl<T extends string>({ value, onChange, options }: {
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
}) {
return (
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
{options.map((o) => (
<button key={o.value} type="button" onClick={() => onChange(o.value)}
className={cn("px-3 py-1 text-sm rounded transition-colors",
value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground")}>
{o.label}
</button>
))}
</div>
)
}
// ─── Country field ────────────────────────────────────────────────────────────
function CountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
@@ -267,30 +159,30 @@ function WanUplinkEditor({ wans, onChange }: {
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="Имя" required>
<FormField label="Имя" required>
<Input className="h-8 font-mono text-xs" placeholder="WAN1-RT"
value={wan.name} onChange={e => updateWan(wan.id, { name: e.target.value })} />
</Field>
<Field label="Интерфейс">
</FormField>
<FormField label="Интерфейс">
<Input className="h-8 font-mono text-xs" placeholder="ether1"
value={wan.iface} onChange={e => updateWan(wan.id, { iface: e.target.value })} />
</Field>
<Field label="Провайдер (ISP)">
</FormField>
<FormField label="Провайдер (ISP)">
<Input className="h-8 text-xs" placeholder="Rostelecom"
value={wan.isp} onChange={e => updateWan(wan.id, { isp: e.target.value })} />
</Field>
<Field label="Внешний IP">
</FormField>
<FormField label="Внешний IP">
<Input className="h-8 font-mono text-xs" placeholder="94.25.168.1"
value={wan.ip} onChange={e => updateWan(wan.id, { ip: e.target.value })} />
</Field>
<Field label="↓ Макс. Мбит">
</FormField>
<FormField label="↓ Макс. Мбит">
<Input className="h-8 font-mono text-xs" type="number" min={1}
value={wan.maxDl} onChange={e => updateWan(wan.id, { maxDl: Number(e.target.value) })} />
</Field>
<Field label="↑ Макс. Мбит">
</FormField>
<FormField label="↑ Макс. Мбит">
<Input className="h-8 font-mono text-xs" type="number" min={1}
value={wan.maxUl} onChange={e => updateWan(wan.id, { maxUl: Number(e.target.value) })} />
</Field>
</FormField>
</div>
</div>
))}
@@ -328,10 +220,11 @@ export default function ServersPage() {
const [_backendOk, setBackendOk] = useState(false)
const [search, setSearch] = useState("")
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
const [open, setOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<SheetMode>("add")
const [editingId, setEditingId] = useState<string | null>(null)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [sheetStep, setSheetStep] = useState(1)
const [form, setForm] = useState<FormState>(defaultForm)
const [testState, setTestState] = useState<TestState>("idle")
const [testMsg, setTestMsg] = useState("")
@@ -368,6 +261,7 @@ export default function ServersPage() {
function openAdd() {
setSheetMode("add"); setEditingId(null)
setForm(defaultForm); setTestState("idle"); setTestMsg("")
setSheetStep(1)
setOpen(true)
}
@@ -381,7 +275,7 @@ export default function ServersPage() {
lanSubnet: s.lanSubnet ?? "",
wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [],
})
setTestState("idle"); setTestMsg(""); setOpen(true)
setTestState("idle"); setTestMsg(""); setSheetStep(1); setOpen(true)
// Fetch full server details (including credentials) from backend
if (isLive) {
@@ -549,13 +443,14 @@ export default function ServersPage() {
// ── derived ──────────────────────────────────────────────────────────────
const filtered = useMemo(() => {
return serverList.filter(sv => {
const base = serverList.filter(sv => {
if (typeFilter !== "all" && sv.type !== typeFilter) return false
if (!search) return true
const q = search.toLowerCase()
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
})
}, [serverList, search, typeFilter])
return applyReuiFilters(base, advancedFilters, SERVER_FILTER_ACCESSORS)
}, [serverList, search, typeFilter, advancedFilters])
const counts = useMemo(() => ({
all: serverList.length,
@@ -615,272 +510,34 @@ export default function ServersPage() {
</div>
{/* Table */}
<Card>
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{tabs.map(tab => (
<button key={tab.value} onClick={() => setTypeFilter(tab.value)}
className={cn(
"flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors",
typeFilter === tab.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}>
{tab.label}
<span className="text-xs tabular-nums opacity-60">
{tab.value === "all" ? counts.all : counts[tab.value as ServerType]}
</span>
</button>
))}
</div>
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, хосту…" value={search} onChange={e => setSearch(e.target.value)} />
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} серверов</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3">Имя / Хост</th>
<th className="text-left font-medium px-4 py-3">Тип</th>
<th className="text-left font-medium px-4 py-3">Модель</th>
<th className="text-left font-medium px-4 py-3">RouterOS</th>
<th className="text-left font-medium px-4 py-3">Площадка</th>
<th className="text-left font-medium px-4 py-3">WAN / LAN</th>
<th className="text-right font-medium px-4 py-3">Задержка</th>
<th className="text-left font-medium px-4 py-3">Статус</th>
<th className="w-10 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map(s => {
const isExpanded = expandedId === s.id
const ver = rosVer(s.os)
return (
<Fragment key={s.id}>
<tr
className={cn(
"hover:bg-muted/40 transition-colors cursor-pointer",
isExpanded && "bg-muted/30",
)}
onClick={() => setExpandedId(prev => prev === s.id ? null : s.id)}
>
{/* Expand chevron + name */}
<td className="px-5 py-3">
<div className="flex items-start gap-2">
{isExpanded
? <ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
: <ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />}
<div className="min-w-0">
<p className="font-medium truncate">{s.name}</p>
<p className="text-xs font-mono text-muted-foreground">{s.host}</p>
{s.ipv6Address && (
<p className="text-[10px] font-mono text-sky-500/70 truncate max-w-[150px]" title={s.ipv6Address}>
{s.ipv6Address}
</p>
)}
</div>
</div>
</td>
<td className="px-4 py-3"><TypeBadge type={s.type} /></td>
<td className="px-4 py-3 text-muted-foreground text-xs">{s.model}</td>
<td className="px-4 py-3"><RosBadge os={s.os} /></td>
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
<Flag code={s.country} />
<span className="font-medium">{s.site}</span>
</div>
</td>
{/* WAN / LAN column */}
<td className="px-4 py-3">
{s.type === "home-router" && s.wanUplinks?.length ? (
<div className="flex flex-col gap-0.5">
{s.wanUplinks.map(w => (
<div key={w.id} className="flex items-center gap-1.5 text-[11px] font-mono">
<WifiIcon className="size-3 text-sky-400 shrink-0" />
<span className="font-semibold text-sky-600 dark:text-sky-400">{w.name}</span>
<span className="text-muted-foreground">{w.isp}</span>
<span className="text-muted-foreground">{w.maxDl}{w.maxUl}</span>
</div>
))}
{s.lanSubnet && (
<div className="text-[10px] font-mono text-muted-foreground mt-0.5">
LAN {s.lanSubnet}
</div>
)}
</div>
) : (
<div className="flex flex-col gap-0.5">
{s.wireGuardIfaces && s.wireGuardIfaces.length > 0 && (
<div className="text-[11px] font-mono text-violet-500 dark:text-violet-400 flex items-center gap-1">
<ShieldIcon className="size-3" />
WG: {s.wireGuardIfaces.length} iface · {s.wireGuardIfaces.reduce((n, i) => n + i.peers.length, 0)} peers
</div>
)}
{s.rpkiEnabled && (
<div className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400">RPKI </div>
)}
{!s.wireGuardIfaces?.length && !s.rpkiEnabled && (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
)}
</td>
<td className={cn("px-4 py-3 font-mono text-right text-sm",
s.latency == null ? "text-muted-foreground"
: s.latency > 60 ? "text-[var(--status-degraded-fg)]" : "")}>
{s.latency == null ? "—" : `${s.latency} мс`}
</td>
<td className="px-4 py-3"><StatusBadge status={s.status} /></td>
<td className="px-3 py-3" onClick={e => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7">
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuGroup>
<DropdownMenuLabel>{s.name}</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => window.open(`https://${s.host}`, "_blank")}>
<ExternalLinkIcon className="size-3.5" />Открыть WebFig
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openEdit(s)}>
<PencilIcon className="size-3.5" />Редактировать
</DropdownMenuItem>
{isLive && (
<DropdownMenuItem onClick={() => handlePoll(s.id)} disabled={pollingIds.has(s.id)}>
<RefreshCwIcon className={cn("size-3.5", pollingIds.has(s.id) && "animate-spin")} />
{pollingIds.has(s.id) ? "Опрос…" : "Опросить"}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => handleToggleStatus(s.id)}>
<PowerIcon className="size-3.5" />
{s.status === "offline" ? "Включить" : "Отключить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => handleDelete(s.id)}>
<Trash2Icon className="size-3.5" />Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
{/* ── Expandable detail row ── */}
{isExpanded && (
<tr className="bg-muted/20">
<td colSpan={9} className="px-8 py-5 border-b border-border/50">
<div className="flex flex-col gap-5">
{/* Snapshot / live data */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
{s.model && s.model !== "—" && (
<span className="text-muted-foreground">Модель: <span className="font-mono text-foreground">{s.model}</span></span>
)}
{s.uptime && (
<span className="text-muted-foreground">Uptime: <span className="font-mono text-foreground">{s.uptime}</span></span>
)}
{s.cpuLoad != null && (
<span className="text-muted-foreground">CPU: <span className={cn("font-mono font-semibold", s.cpuLoad > 80 ? "text-red-400" : s.cpuLoad > 50 ? "text-amber-400" : "text-emerald-400")}>{s.cpuLoad}%</span></span>
)}
{s.asn && (
<span className="text-muted-foreground">ASN: <span className="font-mono text-foreground">{s.asn}</span></span>
)}
{s.ipv6Address && (
<span className="text-muted-foreground">IPv6: <span className="font-mono text-sky-400">{s.ipv6Address}</span></span>
)}
{s.vrfNames?.map(v => (
<span key={v} className="text-muted-foreground">VRF: <span className="font-mono text-foreground">{v}</span></span>
))}
{s.comment && (
<span className="text-muted-foreground italic">{s.comment}</span>
)}
{s.polledAt && (
<span className="text-muted-foreground/50 text-[11px]">
Опрошен: {new Date(s.polledAt).toLocaleString("ru")}
</span>
)}
{!s.polledAt && (
<span className="text-amber-500/70 text-[11px]"> Ещё не опрашивался</span>
)}
</div>
{isLive && (
<Button
variant="outline" size="sm"
className="h-7 gap-1.5 text-xs shrink-0"
disabled={pollingIds.has(s.id)}
onClick={e => { e.stopPropagation(); handlePoll(s.id) }}
>
<RefreshCwIcon className={cn("size-3.5", pollingIds.has(s.id) && "animate-spin")} />
{pollingIds.has(s.id) ? "Опрос…" : "Опросить сейчас"}
</Button>
)}
</div>
{/* Feature matrix */}
<div>
<div className="flex items-center gap-3 mb-3">
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
Возможности RouterOS
</p>
<RosBadge os={s.os} />
<span className="text-[11px] text-muted-foreground">
{ver >= 715
? "✓ Актуальная версия — все ключевые фичи доступны"
: ver >= 710
? "⚠ Рекомендуется обновление до 7.15+"
: s.os !== "—"
? "✗ Устаревшая версия — требуется обновление"
: "Нет данных — нажмите «Опросить сейчас»"}
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
{ROS_FEATURES.map(f => {
const ok = ver >= f.minVer
return (
<div key={f.name} className={cn(
"flex items-start gap-2 rounded-md border px-3 py-2.5 transition-colors",
ok
? "border-emerald-500/25 bg-emerald-500/5"
: "border-border/40 bg-background/40 opacity-60",
)}>
{ok
? <CheckCircleIcon className="size-3.5 text-emerald-500 shrink-0 mt-0.5" />
: <XCircleIcon className="size-3.5 text-muted-foreground/40 shrink-0 mt-0.5" />}
<div className="min-w-0">
<p className={cn(
"text-xs font-medium leading-tight truncate",
ok ? "text-foreground" : "text-muted-foreground",
)}>
{f.name}
</p>
<p className="text-[10px] text-muted-foreground leading-tight mt-0.5">
{f.label} · {f.desc}
</p>
</div>
</div>
)
})}
</div>
</div>
</div>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
<Card className="overflow-hidden py-0 gap-0">
<DataPageToolbar
segmented={{
value: typeFilter,
onChange: setTypeFilter,
options: tabs.map((tab) => ({
value: tab.value,
label: tab.label,
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
})),
}}
filters={advancedFilters}
onFiltersChange={setAdvancedFilters}
filterFields={SERVER_FILTER_FIELDS}
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, хосту…"
countLabel={`${filtered.length} серверов`}
/>
<ServersDataGrid
servers={filtered}
isLive={isLive}
pollingIds={pollingIds}
onPoll={handlePoll}
onEdit={openEdit}
onDelete={handleDelete}
onToggleStatus={handleToggleStatus}
/>
</Card>
</div>
</div>
@@ -893,18 +550,45 @@ export default function ServersPage() {
<SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
<StepperItem step={1}>
<StepperTrigger>
<StepperIndicator>1</StepperIndicator>
<StepperTitle className="sr-only">Основные</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger>
<StepperIndicator>2</StepperIndicator>
<StepperTitle className="sr-only">WAN</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={3}>
<StepperTrigger>
<StepperIndicator>3</StepperIndicator>
<StepperTitle className="sr-only">API</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={4}>
<StepperTrigger>
<StepperIndicator>4</StepperIndicator>
<StepperTitle className="sr-only">Дополнительно</StepperTitle>
</StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
<StepperContent value={1} className="flex flex-col gap-4">
{/* 1. Основные */}
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<Field label="Имя сервера" required hint="Например home-msk-01">
<FormField label="Имя сервера" required hint="Например home-msk-01">
<Input className="font-mono" placeholder="home-msk-01"
value={form.name} onChange={e => set("name", e.target.value)} />
</Field>
</FormField>
<Field label="Тип узла" required>
<FormField label="Тип узла" required>
<SegmentedControl
value={form.type}
onChange={v => set("type", v)}
@@ -914,24 +598,24 @@ export default function ServersPage() {
{ value: "exit-node", label: "Exit Node" },
]}
/>
</Field>
</FormField>
<div className="grid grid-cols-2 gap-3">
<Field label="Площадка" required hint="MSK, SPB, FRA…">
<FormField label="Площадка" required hint="MSK, SPB, FRA…">
<Input className="font-mono uppercase" placeholder="MSK"
value={form.site} onChange={e => set("site", e.target.value.toUpperCase())} />
</Field>
</FormField>
{!isHomeRouter && (
<Field label="ASN" hint="Например AS65001">
<FormField label="ASN" hint="Например AS65001">
<Input className="font-mono" placeholder="AS65001"
value={form.asn} onChange={e => set("asn", e.target.value)} />
</Field>
</FormField>
)}
{isHomeRouter && (
<Field label="LAN-подсеть" hint="Например 192.168.10.0/24">
<FormField label="LAN-подсеть" hint="Например 192.168.10.0/24">
<Input className="font-mono" placeholder="192.168.10.0/24"
value={form.lanSubnet} onChange={e => set("lanSubnet", e.target.value)} />
</Field>
</FormField>
)}
</div>
@@ -939,45 +623,43 @@ export default function ServersPage() {
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Включён</span>
<Toggle checked={form.enabled} onChange={v => set("enabled", v)} />
<FormToggle checked={form.enabled} onChange={v => set("enabled", v)} />
</div>
</div>
{/* 2. WAN-аплинки (только для home-router) */}
{isHomeRouter && (
<div className="flex flex-col gap-4">
</StepperContent>
<StepperContent value={2} className="flex flex-col gap-4">
<SectionTitle>WAN-аплинки</SectionTitle>
{!isHomeRouter ? (
<p className="text-sm text-muted-foreground">WAN-аплинки доступны только для типа Home Router.</p>
) : (
<WanUplinkEditor
wans={form.wanUplinks}
onChange={wans => set("wanUplinks", wans)}
/>
</div>
)}
{/* 3. Подключение (API) */}
<div className="flex flex-col gap-4">
)}
</StepperContent>
<StepperContent value={3} className="flex flex-col gap-4">
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>
<Field label="Хост / IP-адрес" required
<FormField label="Хост / IP-адрес" required
hint={isHomeRouter
? "Управляющий LAN-адрес роутера, например 192.168.10.1"
: "Внешний или управляющий IP-адрес роутера"}>
<Input className="font-mono" placeholder={isHomeRouter ? "192.168.10.1" : "203.0.113.1"}
value={form.host} onChange={e => set("host", e.target.value)} />
</Field>
</FormField>
<div className="grid grid-cols-2 gap-3">
<Field label="Протокол">
<FormField label="Протокол">
<SegmentedControl
value={form.proto}
onChange={v => { set("proto", v); set("port", v === "https" ? "443" : "80") }}
options={[{ value: "https", label: "HTTPS" }, { value: "http", label: "HTTP" }]}
/>
</Field>
<Field label="Порт" hint="443 / 80">
</FormField>
<FormField label="Порт" hint="443 / 80">
<Input className="font-mono" placeholder="443"
value={form.port} onChange={e => set("port", e.target.value)} />
</Field>
</FormField>
</div>
<div className="flex items-center justify-between">
@@ -988,13 +670,13 @@ export default function ServersPage() {
</p>
<p className="text-xs text-muted-foreground">Отключить для self-signed сертификатов</p>
</div>
<Toggle checked={form.verifySsl} onChange={v => set("verifySsl", v)} />
<FormToggle checked={form.verifySsl} onChange={v => set("verifySsl", v)} />
</div>
<Field label="Путь API">
<FormField label="Путь API">
<Input className="font-mono" placeholder="/rest"
value={form.apiPath} onChange={e => set("apiPath", e.target.value)} />
</Field>
</FormField>
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground mb-1">RouterOS 7.1+ REST API</p>
@@ -1008,12 +690,12 @@ export default function ServersPage() {
)}
</div>
<Field label="Имя пользователя" required hint="Пользователь RouterOS с доступом к API">
<FormField label="Имя пользователя" required hint="Пользователь RouterOS с доступом к API">
<Input className="font-mono" placeholder="api-user"
value={form.username} onChange={e => set("username", e.target.value)} />
</Field>
</FormField>
<Field label="Пароль" required>
<FormField label="Пароль" required>
<div className="relative">
<Input type={form.showPassword ? "text" : "password"}
className="font-mono pr-9" placeholder="Пароль пользователя RouterOS"
@@ -1024,7 +706,7 @@ export default function ServersPage() {
{form.showPassword ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
</button>
</div>
</Field>
</FormField>
<div className="flex flex-col gap-2">
<Button type="button" variant="outline" size="sm" className="w-fit gap-2"
@@ -1046,10 +728,8 @@ export default function ServersPage() {
</div>
)}
</div>
</div>
{/* 4. Дополнительно */}
<div className="flex flex-col gap-4">
</StepperContent>
<StepperContent value={4} className="flex flex-col gap-4">
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors">
{form.showAdvanced ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
@@ -1059,34 +739,41 @@ export default function ServersPage() {
{form.showAdvanced && (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<Field label="SSH-порт">
<FormField label="SSH-порт">
<Input type="number" className="font-mono" value={form.sshPort}
onChange={e => set("sshPort", Number(e.target.value))} />
</Field>
<Field label="Winbox-порт">
</FormField>
<FormField label="Winbox-порт">
<Input type="number" className="font-mono" value={form.winboxPort}
onChange={e => set("winboxPort", Number(e.target.value))} />
</Field>
</FormField>
</div>
<Field label="Таймаут соединения, с">
<FormField label="Таймаут соединения, с">
<Input type="number" className="font-mono" value={form.timeout}
onChange={e => set("timeout", Number(e.target.value))} />
</Field>
<Field label="Комментарий">
</FormField>
<FormField label="Комментарий">
<Input placeholder="Описание или заметка" value={form.comment}
onChange={e => set("comment", e.target.value)} />
</Field>
</FormField>
</div>
)}
</div>
</div>
</StepperContent>
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
<Button className="flex-1" onClick={handleSave}>
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
</Button>
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
{sheetStep > 1 && (
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
)}
{sheetStep < 4 ? (
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
) : (
<Button className="ml-auto" onClick={handleSave}>
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
+48 -76
View File
@@ -1,8 +1,10 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle } from "@/components/form-kit"
import { FileImportDialog } from "@/components/file-import-dialog"
import {
Card, CardContent, CardHeader, CardTitle, CardDescription,
} from "@/components/ui/card"
@@ -162,17 +164,6 @@ function makeApiFetch(backendUrl: string) {
// ─── small components ─────────────────────────────────────────────────────────
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button type="button" onClick={() => onChange(!checked)}
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
checked ? "bg-primary" : "bg-input")}>
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0")} />
</button>
)
}
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4 py-3.5">
@@ -413,24 +404,24 @@ function UserSheet({ open, user, onSave, onClose }: {
{tab === "profile" && (
<div className="px-5 py-5 flex flex-col gap-4">
<Field label="Полное имя" error={errors.name}>
<FormField label="Полное имя" error={errors.name}>
<Input value={form.name} onChange={e => setField("name", e.target.value)}
placeholder="Иван Иванов" className="h-9" />
</Field>
</FormField>
<Field label="Логин" error={errors.login}>
<FormField label="Логин" error={errors.login}>
<Input value={form.login} onChange={e => setField("login", e.target.value)}
placeholder="i.ivanov" className="h-9 font-mono" />
</Field>
</FormField>
<Field label="Email" error={errors.email}>
<FormField label="Email" error={errors.email}>
<Input value={form.email} onChange={e => setField("email", e.target.value)}
placeholder="i.ivanov@company.io" type="email" className="h-9" />
</Field>
</FormField>
<Separator />
<Field label="Роль">
<FormField label="Роль">
<div className="flex gap-2 flex-wrap">
{(["viewer", "operator", "admin"] as Role[]).map(r => (
<button key={r} type="button" onClick={() => setRole(r)}
@@ -453,18 +444,18 @@ function UserSheet({ open, user, onSave, onClose }: {
? "Управление инфраструктурой согласно выданным правам"
: "Только просмотр согласно выданным правам"}
</p>
</Field>
</FormField>
<Separator />
<Field label="Статус учётной записи">
<FormField label="Статус учётной записи">
<div className="flex items-center gap-3">
<Toggle checked={form.active} onChange={v => setField("active", v)} />
<FormToggle checked={form.active} onChange={v => setField("active", v)} />
<span className={cn("text-xs font-medium", form.active ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground")}>
{form.active ? "Активна" : "Заблокирована"}
</span>
</div>
</Field>
</FormField>
</div>
)}
@@ -660,7 +651,7 @@ function UserSheet({ open, user, onSave, onClose }: {
</span>
{/* active toggle */}
<Toggle checked={su.active} onChange={() => toggleSubUser(su.id)} />
<FormToggle checked={su.active} onChange={() => toggleSubUser(su.id)} />
{/* delete */}
<button onClick={() => removeSubUser(su.id)}
@@ -775,22 +766,6 @@ function UserSheet({ open, user, onSave, onClose }: {
)
}
// ─── helper ───────────────────────────────────────────────────────────────────
function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">{label}</label>
{children}
{error && (
<p className="text-xs text-destructive flex items-center gap-1">
<AlertCircleIcon className="size-3" />{error}
</p>
)}
</div>
)
}
// ─── delete confirm ───────────────────────────────────────────────────────────
function DatabaseRestoreConfirm({
@@ -900,7 +875,7 @@ export default function SettingsPage() {
const [dbBackupBusy, setDbBackupBusy] = useState(false)
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
const dbRestoreInputRef = useRef<HTMLInputElement>(null)
const [dbRestoreDialogOpen, setDbRestoreDialogOpen] = useState(false)
// notifications
const [notifEmail, setNotifEmail] = useState(true)
@@ -1031,7 +1006,6 @@ export default function SettingsPage() {
await restoreSystemDatabaseBackup(backendUrl, dbRestoreFile)
toast.success("База приложения восстановлена")
setDbRestoreFile(null)
if (dbRestoreInputRef.current) dbRestoreInputRef.current.value = ""
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось восстановить базу")
} finally {
@@ -1230,30 +1204,16 @@ export default function SettingsPage() {
description="Полностью заменяет текущую базу SQLite"
>
<div className="flex flex-col items-end gap-2">
<Input
ref={dbRestoreInputRef}
type="file"
accept=".db,.sqlite,.sqlite3,application/octet-stream"
className="hidden"
<Button
variant="outline"
size="sm"
className="h-8"
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
onChange={(e) => {
const file = e.target.files?.[0] ?? null
if (!file) return
setDbRestoreFile(file)
}}
/>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
onClick={() => dbRestoreInputRef.current?.click()}
>
<UploadIcon className="size-4" />
Выбрать файл
</Button>
</div>
onClick={() => setDbRestoreDialogOpen(true)}
>
<UploadIcon className="size-4" />
Выбрать файл
</Button>
{dbRestoreFile && (
<p className="text-xs text-muted-foreground max-w-[220px] text-right break-all">{dbRestoreFile.name}</p>
)}
@@ -1384,7 +1344,7 @@ export default function SettingsPage() {
label="Подставлять данные EvoBGP"
description="На страницах Домены, IP-диапазоны, ASN и Communities вместо моков из lib/data"
>
<Toggle
<FormToggle
checked={evoEnabledDraft}
onChange={(v) => setEvoEnabledDraft(v)}
/>
@@ -1496,15 +1456,15 @@ export default function SettingsPage() {
<CardHeader><CardTitle className="text-base">Каналы уведомлений</CardTitle></CardHeader>
<CardContent className="divide-y px-5">
<SettingRow label="Email" description="Отправка уведомлений на admin@routerlists.io">
<Toggle checked={notifEmail} onChange={setNotifEmail} />
<FormToggle checked={notifEmail} onChange={setNotifEmail} />
</SettingRow>
{notifEmail && <div className="py-3"><Input className="text-sm h-8" defaultValue="admin@routerlists.io" /></div>}
<SettingRow label="Slack" description="Webhook-интеграция с каналом #alerts">
<Toggle checked={notifSlack} onChange={setNotifSlack} />
<FormToggle checked={notifSlack} onChange={setNotifSlack} />
</SettingRow>
{notifSlack && <div className="py-3"><Input className="text-sm h-8 font-mono" placeholder="https://hooks.slack.com/…" /></div>}
<SettingRow label="Webhook" description="POST-запрос на произвольный endpoint">
<Toggle checked={notifWh} onChange={setNotifWh} />
<FormToggle checked={notifWh} onChange={setNotifWh} />
</SettingRow>
{notifWh && <div className="py-3"><Input className="text-sm h-8 font-mono" defaultValue="https://hooks.example.com/routerlists" /></div>}
</CardContent>
@@ -1513,16 +1473,16 @@ export default function SettingsPage() {
<CardHeader><CardTitle className="text-base">Триггеры</CardTitle></CardHeader>
<CardContent className="divide-y px-5">
<SettingRow label="Деградация узла" description="Потери пакетов > 5% или RTT > 100мс">
<Toggle checked={notifDegr} onChange={setNotifDegr} />
<FormToggle checked={notifDegr} onChange={setNotifDegr} />
</SettingRow>
<SettingRow label="Узел ушёл offline">
<Toggle checked={notifOffline} onChange={setNotifOffline} />
<FormToggle checked={notifOffline} onChange={setNotifOffline} />
</SettingRow>
<SettingRow label="Падение BGP-сессии">
<Toggle checked={notifBgp} onChange={setNotifBgp} />
<FormToggle checked={notifBgp} onChange={setNotifBgp} />
</SettingRow>
<SettingRow label="Просроченный бэкап" description="Если последний бэкап старше 2 дней">
<Toggle checked={notifBackup} onChange={setNotifBackup} />
<FormToggle checked={notifBackup} onChange={setNotifBackup} />
</SettingRow>
</CardContent>
</Card>
@@ -1741,7 +1701,7 @@ export default function SettingsPage() {
<CardContent className="divide-y px-5">
<SettingRow label="Двухфакторная аутентификация (MFA)"
description="TOTP / Authenticator app для всех администраторов">
<Toggle checked={mfa} onChange={setMfa} />
<FormToggle checked={mfa} onChange={setMfa} />
</SettingRow>
<SettingRow label="Тайм-аут сессии (мин)" description="Автоматический выход при бездействии">
<Input className="w-20 h-8 text-sm" value={sessMin} onChange={e => setSessMin(e.target.value)} />
@@ -1763,7 +1723,7 @@ export default function SettingsPage() {
<CardContent className="divide-y px-5">
<SettingRow label="Расширенный журнал аудита"
description="Записывать все изменения конфигурации с указанием пользователя и IP">
<Toggle checked={auditLog} onChange={setAuditLog} />
<FormToggle checked={auditLog} onChange={setAuditLog} />
</SettingRow>
</CardContent>
</Card>
@@ -1853,7 +1813,6 @@ export default function SettingsPage() {
onCancel={() => {
if (dbRestoreBusy) return
setDbRestoreFile(null)
if (dbRestoreInputRef.current) dbRestoreInputRef.current.value = ""
}}
/>
)}
@@ -1864,6 +1823,19 @@ export default function SettingsPage() {
onCancel={() => setDeleteTarget(null)}
/>
)}
<FileImportDialog
open={dbRestoreDialogOpen}
onOpenChange={setDbRestoreDialogOpen}
title="Восстановление базы данных"
description="Выберите файл SQLite (.db) — текущая база будет полностью заменена"
accept=".db,.sqlite,.sqlite3,application/octet-stream"
onImport={async (files) => {
const file = files[0]
if (!file) return
setDbRestoreFile(file)
}}
/>
</div>
)
}
+27 -83
View File
@@ -2,6 +2,7 @@
import { useState, useMemo, useEffect, useRef, useCallback } from "react"
import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -212,21 +213,6 @@ function probeGroupActionKey(srvId: string, group: { name: string; target: strin
// ── shared components ──────────────────────────────────────────────────────────
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button type="button" onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
checked ? "bg-primary" : "bg-input",
)}>
<span className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0",
)} />
</button>
)
}
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
return (
<span className={cn(
@@ -267,18 +253,6 @@ function StatChip({
)
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">
{label}
{hint && <span className="font-normal text-muted-foreground ml-1">{hint}</span>}
</label>
{children}
</div>
)
}
/** Активный интерфейс RouterOS: не disabled и running */
function isActiveRouterOsInterface(i: { running?: boolean; disabled?: boolean }): boolean {
return i.running === true && i.disabled !== true
@@ -323,36 +297,6 @@ function interfaceOptionMatchesSearch(iface: RouterInterfaceOption, raw: string)
return false
}
function SegmentedControl<T extends string>({
value,
onChange,
options,
}: {
value: T
onChange: (v: T) => void
options: Array<{ value: T; label: string }>
}) {
return (
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
"px-3 py-1 text-sm rounded transition-colors",
value === option.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
))}
</div>
)
}
function ServerPickerCards({
options,
selectedId,
@@ -2401,7 +2345,7 @@ export default function UptimePage() {
className={cn("px-4 py-3 hover:bg-muted/20 transition-colors", !probe.enabled && "opacity-50")}>
<div className="flex items-center gap-3 flex-wrap">
{/* enable toggle */}
<Toggle checked={probe.enabled} onChange={(v) => updateSpeedProbe(probe.id, { enabled: v })} />
<FormToggle checked={probe.enabled} onChange={(v) => updateSpeedProbe(probe.id, { enabled: v })} />
{/* route: src → dst */}
<div className="flex items-center gap-1.5 min-w-0 flex-1">
@@ -2824,7 +2768,7 @@ export default function UptimePage() {
!p.enabled && "opacity-40",
)}
style={{ gridTemplateColumns: "36px 16px 130px 120px 140px 70px 44px minmax(132px,1fr) 96px 36px 72px" }}>
<Toggle checked={p.enabled} onChange={v => toggleProbe(p.id, v)} />
<FormToggle checked={p.enabled} onChange={v => toggleProbe(p.id, v)} />
<StatusDot
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
pulse={p.status === "up" && p.enabled}
@@ -3002,7 +2946,7 @@ export default function UptimePage() {
</SheetHeader>
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-5">
<Field label="Источник">
<FormField label="Источник">
<ServerPickerCards
options={selectableSources}
selectedId={speedDraft.srcServerId}
@@ -3015,9 +2959,9 @@ export default function UptimePage() {
void loadSpeedInterfaces(nextSrc)
}}
/>
</Field>
</FormField>
<Field label="Назначение">
<FormField label="Назначение">
<ServerPickerCards
options={selectableSources.filter((s) => s.id !== speedDraft.srcServerId)}
selectedId={speedDraft.dstServerId}
@@ -3026,28 +2970,28 @@ export default function UptimePage() {
void loadSpeedInterfaces(nextDst)
}}
/>
</Field>
</FormField>
<Field label="Интерфейс источника">
<FormField label="Интерфейс источника">
<InterfacePickerCards
value={speedDraft.srcInterface}
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, srcInterface: v }))}
options={filterActiveInterfaces(speedIfaces[speedDraft.srcServerId] ?? [])}
autoLabel="auto"
/>
</Field>
</FormField>
<Field label="Интерфейс назначения">
<FormField label="Интерфейс назначения">
<InterfacePickerCards
value={speedDraft.dstInterface}
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, dstInterface: v }))}
options={filterActiveInterfaces(speedIfaces[speedDraft.dstServerId] ?? [])}
autoLabel="auto"
/>
</Field>
</FormField>
<div className="grid grid-cols-3 gap-3">
<Field label="Протокол">
<FormField label="Протокол">
<SegmentedControl
value={speedDraft.protocol}
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, protocol: v }))}
@@ -3056,8 +3000,8 @@ export default function UptimePage() {
{ value: "udp", label: "UDP" },
]}
/>
</Field>
<Field label="Direction">
</FormField>
<FormField label="Direction">
<SegmentedControl
value={speedDraft.direction}
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, direction: v }))}
@@ -3067,10 +3011,10 @@ export default function UptimePage() {
{ value: "receive", label: "rx" },
]}
/>
</Field>
<Field label="Сек">
</FormField>
<FormField label="Сек">
<Input className="h-9" value={speedDraft.durationSec} onChange={(e) => setSpeedDraft((prev) => ({ ...prev, durationSec: e.target.value }))} />
</Field>
</FormField>
</div>
{speedDraft.srcServerId &&
@@ -3169,7 +3113,7 @@ export default function UptimePage() {
</div>
</div>
<Field
<FormField
label="Источник (кто пингует)"
hint="— весь каталог, в т.ч. выключенные в инвентаре (Home Router часто «выкл.», но доступен по LAN для ping)"
>
@@ -3178,9 +3122,9 @@ export default function UptimePage() {
selectedId={newSrcId}
onSelect={(id) => setNewSrcId(id)}
/>
</Field>
</FormField>
<Field label="Интерфейс источника" hint="(необязательно)">
<FormField label="Интерфейс источника" hint="(необязательно)">
<InterfacePickerCards
value={newSrcInterface}
onChange={setNewSrcInterface}
@@ -3188,25 +3132,25 @@ export default function UptimePage() {
autoLabel="авто (по маршруту)"
busy={srcInterfacesBusy}
/>
</Field>
</FormField>
<Field label="Имя пробы">
<FormField label="Имя пробы">
<Input className="h-9 text-sm" placeholder="youtube.com"
value={newName} onChange={e => setNewName(e.target.value)} />
</Field>
</FormField>
<Field label="Целевой IP / хост">
<FormField label="Целевой IP / хост">
<Input className="h-9 text-sm font-mono" placeholder="142.250.74.110"
value={newTarget} onChange={e => setNewTarget(e.target.value)} />
</Field>
</FormField>
<Field label="Связанный фильтр" hint="(необязательно)">
<FormField label="Связанный фильтр" hint="(необязательно)">
<LinkedFilterPickerCards
value={newFilter}
onChange={setNewFilter}
items={filters}
/>
</Field>
</FormField>
</div>
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
+7 -5
View File
@@ -2,6 +2,7 @@
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { EmptyState } from "@/components/empty-state"
import { servers } from "@/lib/data"
import type { WireGuardInterface, WireGuardPeer } from "@/lib/data"
import { Flag } from "@/components/flag"
@@ -410,11 +411,12 @@ export default function WireGuardPage() {
</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>
<EmptyState
icon={<ShieldCheckIcon className="size-4" />}
title="Нет WireGuard интерфейсов"
description="Добавьте первый интерфейс или проверьте поиск"
className="border-0 py-16"
/>
) : (
filtered.map((iface) => (
<IfaceRow
+13
View File
@@ -0,0 +1,13 @@
import type { NextRequest } from "next/server"
import { proxyBackendRequest } from "@/lib/proxy-backend-request"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export const maxDuration = 600
export async function GET(request: NextRequest) {
return proxyBackendRequest(request, "/api/system/database/backup", {
method: "GET",
forwardRequestBody: false,
})
}
+10
View File
@@ -0,0 +1,10 @@
import type { NextRequest } from "next/server"
import { proxyBackendRequest } from "@/lib/proxy-backend-request"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export const maxDuration = 600
export async function POST(request: NextRequest) {
return proxyBackendRequest(request, "/api/system/database/restore", { method: "POST" })
}
+27
View File
@@ -39,6 +39,15 @@
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-destructive-foreground: var(--destructive-foreground);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-invert: var(--invert);
--color-invert-foreground: var(--invert-foreground);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
@@ -74,6 +83,15 @@
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: var(--color-red-800);
--info: var(--color-violet-500);
--info-foreground: var(--color-violet-900);
--success: var(--color-emerald-500);
--success-foreground: var(--color-emerald-900);
--warning: var(--color-yellow-500);
--warning-foreground: var(--color-yellow-900);
--invert: var(--color-zinc-900);
--invert-foreground: var(--color-zinc-50);
/* Borders + inputs */
--border: oklch(0.904 0.006 264.0);
@@ -148,6 +166,15 @@
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: var(--color-red-600);
--info: var(--color-violet-500);
--info-foreground: var(--color-violet-600);
--success: var(--color-emerald-500);
--success-foreground: var(--color-emerald-600);
--warning: var(--color-yellow-500);
--warning-foreground: var(--color-yellow-600);
--invert: var(--color-zinc-700);
--invert-foreground: var(--color-zinc-50);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
+1
View File
@@ -4,3 +4,4 @@ dist/
*.db-shm
*.db-wal
.env
storage/backups/
+50
View File
@@ -1,4 +1,6 @@
import Database from "better-sqlite3"
import { existsSync, readFileSync } from "node:fs"
import path from "node:path"
type SqliteHandle = InstanceType<typeof Database>
import { drizzle } from "drizzle-orm/better-sqlite3"
@@ -391,6 +393,19 @@ CREATE TABLE IF NOT EXISTS backup_schedule_settings (
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS backup_entries (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL,
server_name TEXT NOT NULL,
filename TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'manual',
notes TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
CREATE TABLE IF NOT EXISTS alert_rules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@@ -669,6 +684,41 @@ SELECT 1, NULL
WHERE NOT EXISTS (SELECT 1 FROM alert_engine_cursor WHERE id = 1);
`)
const backupEntryCount = sqlite.prepare(`SELECT COUNT(*) AS c FROM backup_entries`).get() as { c: number }
if (backupEntryCount.c === 0) {
const legacyIndexPath = path.resolve(process.cwd(), "storage", "backups", "index.json")
if (existsSync(legacyIndexPath)) {
try {
const parsed = JSON.parse(readFileSync(legacyIndexPath, "utf8")) as unknown
if (Array.isArray(parsed)) {
const insert = sqlite.prepare(`
INSERT OR IGNORE INTO backup_entries (id, server_id, server_name, filename, size_bytes, kind, notes, created_at)
VALUES (@id, @serverId, @serverName, @filename, @sizeBytes, @kind, @notes, @createdAt)
`)
for (const row of parsed) {
if (!row || typeof row !== "object") continue
const item = row as Record<string, unknown>
const id = String(item.id ?? "").trim()
const filename = String(item.filename ?? "").trim()
if (!id || !filename) continue
insert.run({
id,
serverId: String(item.serverId ?? ""),
serverName: String(item.serverName ?? ""),
filename,
sizeBytes: Number(item.sizeBytes ?? 0) || 0,
kind: item.kind === "auto" ? "auto" : "manual",
notes: item.notes == null ? null : String(item.notes),
createdAt: String(item.createdAt ?? new Date().toISOString()),
})
}
}
} catch {
/* legacy index.json не читается — пропускаем */
}
}
}
export const db = drizzle(sqlite, { schema })
/** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */
+12
View File
@@ -340,6 +340,17 @@ export const backupScheduleSettings = sqliteTable("backup_schedule_settings", {
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
export const backupEntries = sqliteTable("backup_entries", {
id: text("id").primaryKey(),
serverId: text("server_id").notNull(),
serverName: text("server_name").notNull(),
filename: text("filename").notNull(),
sizeBytes: integer("size_bytes").notNull(),
kind: text("kind", { enum: ["manual", "auto"] }).notNull().default("manual"),
notes: text("notes"),
createdAt: text("created_at").notNull(),
})
/** Группы правил: ANY = хотя бы одно; ALL = все одновременно в окне тика. */
export const alertGroups = sqliteTable("alert_groups", {
id: text("id").primaryKey(),
@@ -562,6 +573,7 @@ export type AcmeSettingsRow = typeof acmeSettings.$inferSelect
export type CertificateIssueJobRow = typeof certificateIssueJobs.$inferSelect
export type CertificateRenewSettingsRow = typeof certificateRenewSettings.$inferSelect
export type BackupScheduleSettingsRow = typeof backupScheduleSettings.$inferSelect
export type BackupEntryRow = typeof backupEntries.$inferSelect
export type AlertGroupRow = typeof alertGroups.$inferSelect
export type AlertRuleRow = typeof alertRules.$inferSelect
export type AlertRuleTargetRow = typeof alertRuleTargets.$inferSelect
+2
View File
@@ -27,6 +27,8 @@ import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
// ── app factory ────────────────────────────────────────────────────────────────
const app = Fastify({
bodyLimit: 512 * 1024 * 1024,
requestTimeout: 10 * 60 * 1000,
logger: {
transport: {
target: "pino-pretty",
+8 -17
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto"
import { readFile, rm } from "node:fs/promises"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { z } from "zod"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
@@ -8,12 +8,13 @@ import { listServersRead } from "../modules/servers/service/servers-service.js"
import { appendEvent } from "../modules/events/service/events-service.js"
import { refreshScheduler } from "../services/scheduler.js"
import {
deleteBackupRecord,
getBackupById,
getBackupsDir,
getBackupScheduleSettings,
readBackupIndex,
listBackups,
runBackupForServer,
updateBackupScheduleSettings,
writeBackupIndex,
type BackupMeta,
} from "../services/backup-service.js"
@@ -47,11 +48,9 @@ const BackupJobIdParamSchema = z.object({
async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
job.status = "running"
job.startedAt = new Date().toISOString()
const indexRows = await readBackupIndex()
for (const id of ids) {
try {
const meta = await runBackupForServer(id, "manual", notes)
indexRows.unshift(meta)
job.created.push(meta)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
@@ -60,7 +59,6 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
job.completed += 1
}
}
await writeBackupIndex(indexRows)
job.status = "done"
job.finishedAt = new Date().toISOString()
appendEvent({
@@ -82,9 +80,7 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/backups", async (_req, reply) => {
const rows = await readBackupIndex()
rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
return reply.send(rows)
return reply.send(listBackups())
})
app.get("/backups/schedule", async (_req, reply) => {
@@ -171,8 +167,7 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
})
app.get("/backups/:id/download", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
const rows = await readBackupIndex()
const hit = rows.find((r) => r.id === req.params.id)
const hit = getBackupById(req.params.id)
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
const filePath = path.join(getBackupsDir(), hit.filename)
const content = await readFile(filePath, "utf8").catch(() => null)
@@ -183,12 +178,8 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
})
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
const rows = await readBackupIndex()
const idx = rows.findIndex((r) => r.id === req.params.id)
if (idx < 0) return reply.status(404).send({ error: "Бэкап не найден" })
const [hit] = rows.splice(idx, 1)
await writeBackupIndex(rows)
await rm(path.join(getBackupsDir(), hit.filename), { force: true })
const hit = await deleteBackupRecord(req.params.id)
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
return reply.status(204).send()
})
}
@@ -5,11 +5,9 @@ import {
getBackupScheduleSettings,
isBackupDue,
pruneBackupsForServer,
readBackupIndex,
resolveBackupServerIds,
runBackupForServer,
touchBackupScheduleRunMeta,
writeBackupIndex,
} from "./backup-service.js"
let collecting = false
@@ -62,7 +60,6 @@ export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot>
collecting = true
const started = Date.now()
const serverIds = resolveBackupServerIds(settings)
const indexRows = await readBackupIndex()
appendEvent({
level: "info",
@@ -78,8 +75,7 @@ export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot>
try {
for (const id of serverIds) {
try {
const meta = await runBackupForServer(id, "auto")
indexRows.unshift(meta)
await runBackupForServer(id, "auto")
snapshot.created += 1
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
@@ -88,8 +84,6 @@ export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot>
}
}
await writeBackupIndex(indexRows)
for (const id of serverIds) {
snapshot.pruned += await pruneBackupsForServer(id, settings.keepCount)
}
+76 -44
View File
@@ -1,17 +1,16 @@
import { randomUUID } from "node:crypto"
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"
import { mkdir, rm, stat, writeFile } from "node:fs/promises"
import path from "node:path"
import { eq } from "drizzle-orm"
import { desc, eq } from "drizzle-orm"
import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups"
import { db } from "../db/index.js"
import { backupScheduleSettings } from "../db/schema.js"
import { backupEntries, backupScheduleSettings } from "../db/schema.js"
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
import { listServersRead } from "../modules/servers/service/servers-service.js"
import { MikrotikClient } from "./mikrotik.js"
const SETTINGS_ID = 1
const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups")
const INDEX_PATH = path.join(BACKUPS_DIR, "index.json")
export type BackupMeta = {
id: string
@@ -24,25 +23,51 @@ export type BackupMeta = {
notes?: string
}
function rowToMeta(row: typeof backupEntries.$inferSelect): BackupMeta {
return {
id: row.id,
serverId: row.serverId,
serverName: row.serverName,
filename: row.filename,
sizeBytes: row.sizeBytes,
createdAt: row.createdAt,
kind: row.kind,
notes: row.notes ?? undefined,
}
}
export async function ensureBackupStorage(): Promise<void> {
await mkdir(BACKUPS_DIR, { recursive: true })
}
export async function readBackupIndex(): Promise<BackupMeta[]> {
await ensureBackupStorage()
try {
const raw = await readFile(INDEX_PATH, "utf8")
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed as BackupMeta[]
} catch {
return []
}
export function listBackups(): BackupMeta[] {
return db.select().from(backupEntries).orderBy(desc(backupEntries.createdAt)).all().map(rowToMeta)
}
export async function writeBackupIndex(rows: BackupMeta[]): Promise<void> {
await ensureBackupStorage()
await writeFile(INDEX_PATH, JSON.stringify(rows, null, 2), "utf8")
export function getBackupById(id: string): BackupMeta | null {
const row = db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1).all()[0]
return row ? rowToMeta(row) : null
}
export function insertBackup(meta: BackupMeta): void {
db.insert(backupEntries).values({
id: meta.id,
serverId: meta.serverId,
serverName: meta.serverName,
filename: meta.filename,
sizeBytes: meta.sizeBytes,
kind: meta.kind,
notes: meta.notes ?? null,
createdAt: meta.createdAt,
}).run()
}
export async function deleteBackupRecord(id: string): Promise<BackupMeta | null> {
const hit = getBackupById(id)
if (!hit) return null
db.delete(backupEntries).where(eq(backupEntries.id, id)).run()
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
return hit
}
function fmtTs(d = new Date()): string {
@@ -152,7 +177,22 @@ export function resolveBackupServerIds(settings: BackupScheduleSettingsDto): str
return [...new Set(requested)].filter((id) => enabled.has(id))
}
function sameLocalSlot(a: Date, b: Date): boolean {
function scheduledSlotForDate(now: Date, settings: BackupScheduleSettingsDto): Date | null {
if (settings.frequency === "weekly") {
const currentDow = (now.getDay() + 6) % 7
if (currentDow !== settings.weekDay) return null
} else if (settings.frequency === "monthly") {
if (now.getDate() !== settings.monthDay) return null
}
const slot = new Date(now)
slot.setSeconds(0, 0)
slot.setMilliseconds(0)
slot.setHours(settings.hour, settings.minute, 0, 0)
return slot
}
function sameLocalMinute(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate()
@@ -160,27 +200,21 @@ function sameLocalSlot(a: Date, b: Date): boolean {
&& a.getMinutes() === b.getMinutes()
}
/** Срабатывает только в минуту расписания; 60 с в UI — интервал проверки, не частота бэкапа. */
export function isBackupDue(now: Date, settings: BackupScheduleSettingsDto, lastRunAt: string | null | undefined): boolean {
if (!settings.enabled) return false
const slot = new Date(now)
slot.setSeconds(0, 0)
slot.setHours(settings.hour, settings.minute, 0, 0)
if (settings.frequency === "weekly") {
const currentDow = (now.getDay() + 6) % 7
if (currentDow !== settings.weekDay) return false
} else if (settings.frequency === "monthly") {
if (now.getDate() !== settings.monthDay) return false
}
const slot = scheduledSlotForDate(now, settings)
if (!slot) return false
if (now < slot) return false
if (!sameLocalMinute(now, slot)) return false
if (lastRunAt) {
const prev = new Date(lastRunAt)
if (Number.isNaN(prev.getTime())) return true
if (settings.frequency === "daily" && sameLocalSlot(prev, slot)) return false
if (settings.frequency === "weekly" && sameLocalSlot(prev, slot)) return false
if (settings.frequency === "monthly" && prev.getFullYear() === slot.getFullYear() && prev.getMonth() === slot.getMonth() && prev.getDate() === slot.getDate()) return false
if (sameLocalMinute(prev, slot)) return false
}
return true
}
@@ -203,9 +237,10 @@ export async function runBackupForServer(
const safeServer = row.name.replace(/[^a-zA-Z0-9._-]+/g, "_")
const filename = `${safeServer}_${ts}.rsc`
const filePath = path.join(BACKUPS_DIR, filename)
await ensureBackupStorage()
await writeFile(filePath, script, "utf8")
const st = await stat(filePath)
return {
const meta: BackupMeta = {
id: randomUUID(),
serverId: String(row.id),
serverName: row.name,
@@ -215,27 +250,24 @@ export async function runBackupForServer(
kind,
notes,
}
insertBackup(meta)
return meta
}
export async function pruneBackupsForServer(serverId: string, keepCount: number): Promise<number> {
const rows = await readBackupIndex()
const forServer = rows.filter((r) => r.serverId === serverId)
if (forServer.length <= keepCount) return 0
const sorted = [...forServer].sort((a, b) => b.createdAt.localeCompare(a.createdAt))
const toDelete = sorted.slice(keepCount)
const deleteIds = new Set(toDelete.map((r) => r.id))
const rows = db.select().from(backupEntries)
.where(eq(backupEntries.serverId, serverId))
.orderBy(desc(backupEntries.createdAt))
.all()
if (rows.length <= keepCount) return 0
const toDelete = rows.slice(keepCount)
for (const hit of toDelete) {
db.delete(backupEntries).where(eq(backupEntries.id, hit.id)).run()
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
}
const next = rows.filter((r) => !deleteIds.has(r.id))
await writeBackupIndex(next)
return toDelete.length
}
export function getBackupsDir(): string {
return BACKUPS_DIR
}
export function getBackupIndexPath(): string {
return INDEX_PATH
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-201
View File
@@ -1,201 +0,0 @@
[
{
"id": "bec5c501-6d3f-4921-9774-5f0e3d526d87",
"serverId": "6",
"serverName": "ihor.msk.rt.shx.su",
"filename": "ihor.msk.rt.shx.su_2026-05-12_21-45-18.rsc",
"sizeBytes": 711251,
"createdAt": "2026-05-12T14:45:18.144Z",
"kind": "auto"
},
{
"id": "2c1d9c24-b612-4756-8352-afd127b2fa81",
"serverId": "5",
"serverName": "servhost.nsk.rt.shx.su",
"filename": "servhost.nsk.rt.shx.su_2026-05-12_21-45-14.rsc",
"sizeBytes": 707990,
"createdAt": "2026-05-12T14:45:14.994Z",
"kind": "auto"
},
{
"id": "3eac2f70-a5e8-428c-a50d-59223061827b",
"serverId": "4",
"serverName": "veesp.swe.rt.shx.su",
"filename": "veesp.swe.rt.shx.su_2026-05-12_21-45-08.rsc",
"sizeBytes": 2195,
"createdAt": "2026-05-12T14:45:08.312Z",
"kind": "auto"
},
{
"id": "2c600052-70ff-4525-910d-725d0f4d2cfb",
"serverId": "3",
"serverName": "vpsville.msk.rt.shx.su",
"filename": "vpsville.msk.rt.shx.su_2026-05-12_21-45-07.rsc",
"sizeBytes": 710694,
"createdAt": "2026-05-12T14:45:07.786Z",
"kind": "auto"
},
{
"id": "b97be1bb-9c72-44a8-be27-333ddb58ca24",
"serverId": "2",
"serverName": "Gateway",
"filename": "Gateway_2026-05-12_21-45-05.rsc",
"sizeBytes": 1712337,
"createdAt": "2026-05-12T14:45:05.250Z",
"kind": "auto"
},
{
"id": "891217c8-faa2-40d7-b093-e325f41d8a3d",
"serverId": "6",
"serverName": "ihor.msk.rt.shx.su",
"filename": "ihor.msk.rt.shx.su_2026-05-12_21-44-21.rsc",
"sizeBytes": 711251,
"createdAt": "2026-05-12T14:44:21.828Z",
"kind": "auto"
},
{
"id": "f6bc0abc-c783-4d6f-92b6-d0cc489e6772",
"serverId": "5",
"serverName": "servhost.nsk.rt.shx.su",
"filename": "servhost.nsk.rt.shx.su_2026-05-12_21-44-17.rsc",
"sizeBytes": 707990,
"createdAt": "2026-05-12T14:44:17.008Z",
"kind": "auto"
},
{
"id": "3f8b71df-5879-4fda-98f9-316353db3657",
"serverId": "4",
"serverName": "veesp.swe.rt.shx.su",
"filename": "veesp.swe.rt.shx.su_2026-05-12_21-44-14.rsc",
"sizeBytes": 2195,
"createdAt": "2026-05-12T14:44:14.981Z",
"kind": "auto"
},
{
"id": "0431c73a-a2a2-4100-8940-a7e560649f53",
"serverId": "3",
"serverName": "vpsville.msk.rt.shx.su",
"filename": "vpsville.msk.rt.shx.su_2026-05-12_21-44-14.rsc",
"sizeBytes": 710694,
"createdAt": "2026-05-12T14:44:14.434Z",
"kind": "auto"
},
{
"id": "523315c7-4db9-4e09-8bfb-3f36dd692485",
"serverId": "2",
"serverName": "Gateway",
"filename": "Gateway_2026-05-12_21-44-11.rsc",
"sizeBytes": 1712337,
"createdAt": "2026-05-12T14:44:11.677Z",
"kind": "auto"
},
{
"id": "b820bdc8-d184-4615-92e1-40df3872d554",
"serverId": "2",
"serverName": "Gateway",
"filename": "Gateway_2026-05-07_14-41-51.rsc",
"sizeBytes": 913056,
"createdAt": "2026-05-07T07:41:51.371Z",
"kind": "manual"
},
{
"id": "7d46ee5b-c829-4d42-af90-847026bafd8a",
"serverId": "6",
"serverName": "ihor.msk.rt.shx.su",
"filename": "ihor.msk.rt.shx.su_2026-05-07_14-19-22.rsc",
"sizeBytes": 676415,
"createdAt": "2026-05-07T07:19:22.073Z",
"kind": "manual"
},
{
"id": "c34f71e5-6766-456b-9b8a-9b8efaa91edc",
"serverId": "5",
"serverName": "servhost.nsk.rt.shx.su",
"filename": "servhost.nsk.rt.shx.su_2026-05-07_14-19-19.rsc",
"sizeBytes": 672268,
"createdAt": "2026-05-07T07:19:19.639Z",
"kind": "manual"
},
{
"id": "40e253a1-98a8-40a7-982b-7c5af213f490",
"serverId": "4",
"serverName": "veesp.swe.rt.shx.su",
"filename": "veesp.swe.rt.shx.su_2026-05-07_14-19-17.rsc",
"sizeBytes": 2131,
"createdAt": "2026-05-07T07:19:17.801Z",
"kind": "manual"
},
{
"id": "9199d0a8-9bc0-4d20-8498-b37f1f15262b",
"serverId": "3",
"serverName": "vpsville.msk.rt.shx.su",
"filename": "vpsville.msk.rt.shx.su_2026-05-07_14-19-17.rsc",
"sizeBytes": 675732,
"createdAt": "2026-05-07T07:19:17.272Z",
"kind": "manual"
},
{
"id": "4de5263f-edb4-4ca9-ae90-2247defdc05e",
"serverId": "2",
"serverName": "Gateway",
"filename": "Gateway_2026-05-07_14-19-14.rsc",
"sizeBytes": 913056,
"createdAt": "2026-05-07T07:19:14.772Z",
"kind": "manual"
},
{
"id": "de49d319-a24f-475c-bb6e-8075eff86380",
"serverId": "2",
"serverName": "Gateway",
"filename": "Gateway_2026-05-07_14-16-45.rsc",
"sizeBytes": 911521,
"createdAt": "2026-05-07T07:16:45.543Z",
"kind": "manual",
"notes": "async"
},
{
"id": "ec5b9e31-42cb-40b9-aba7-3501d5145713",
"serverId": "6",
"serverName": "ihor.msk.rt.shx.su",
"filename": "ihor.msk.rt.shx.su_2026-05-07_14-13-58.rsc",
"sizeBytes": 676415,
"createdAt": "2026-05-07T07:13:58.749Z",
"kind": "manual"
},
{
"id": "7909c7de-a548-44d4-bdf7-7c221bfedd36",
"serverId": "5",
"serverName": "servhost.nsk.rt.shx.su",
"filename": "servhost.nsk.rt.shx.su_2026-05-07_14-13-56.rsc",
"sizeBytes": 672268,
"createdAt": "2026-05-07T07:13:56.245Z",
"kind": "manual"
},
{
"id": "63339ebb-eb94-455e-a61b-368523fed7e1",
"serverId": "4",
"serverName": "veesp.swe.rt.shx.su",
"filename": "veesp.swe.rt.shx.su_2026-05-07_14-13-54.rsc",
"sizeBytes": 2131,
"createdAt": "2026-05-07T07:13:54.493Z",
"kind": "manual"
},
{
"id": "daccab1d-f60a-4570-9d11-c7b06491f6f7",
"serverId": "3",
"serverName": "vpsville.msk.rt.shx.su",
"filename": "vpsville.msk.rt.shx.su_2026-05-07_14-13-53.rsc",
"sizeBytes": 675732,
"createdAt": "2026-05-07T07:13:53.791Z",
"kind": "manual"
},
{
"id": "d976cae6-aae8-4f55-9452-71d5480ac8e8",
"serverId": "2",
"serverName": "Gateway",
"filename": "Gateway_2026-05-07_14-13-48.rsc",
"sizeBytes": 913056,
"createdAt": "2026-05-07T07:13:48.326Z",
"kind": "manual"
}
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,44 +0,0 @@
# synthetic export generated by MikrotikManager
# generated-at: 2026-05-07T07:13:21.880Z
/system identity
set name="veesp.swe.rt.shx.su"
/interface
:put "interface name="ether1" mtu=1500 disabled=no"
:put "interface name="MSK-DC" mtu=1350 disabled=no"
:put "interface name="MSK-IHOR" mtu=1400 disabled=no"
:put "interface name="MSK-VPSVILLE" mtu=1400 disabled=no"
:put "interface name="NSK-SERVHOST" mtu=1400 disabled=no"
:put "interface name="br1" mtu=1500 disabled=no"
:put "interface name="gre-tunnel1" mtu=1450 disabled=no"
:put "interface name="lo" mtu=65536 disabled=no"
:put "interface name="wg-tunnel" mtu=1420 disabled=no"
:put "interface name="wg1" mtu=1280 disabled=no"
/ip address
add address=62.182.194.146/24 interface="ether1"
add address=10.200.100.26/30 interface="MSK-IHOR"
add address=10.200.200.2/30 interface="*5"
add address=10.200.40.2/30 interface="MSK-VPSVILLE"
add address=10.205.1.2/30 interface="wg1"
add address=10.200.100.2/30 interface="*11" comment="Interface: MSK-SELECTEL (GRE) to selectel.msk.rt.shx.su"
add address=10.200.100.38/30 interface="MSK-DC" comment="Interface: MSK-DC (GRE) to dc.msk.rt.shx.su"
add address=10.200.100.14/30 interface="MSK-VPSVILLE" comment="Interface: MSK-VPSVILLE (GRE) to vpsville.msk.rt.shx.su"
add address=10.101.1.2/30 interface="gre-tunnel1"
add address=10.200.100.54/30 interface="NSK-SERVHOST" comment="Interface: NSK-SERVHOST (GRE) to servhost.nsk.rt.shx.su"
/ip route
add dst-address=0.0.0.0/0 gateway=62.182.194.1 distance=1
add dst-address=10.101.1.0/30 gateway=gre-tunnel1 distance=0
add dst-address=10.200.100.12/30 gateway=MSK-VPSVILLE distance=0
add dst-address=10.200.100.24/30 gateway=MSK-IHOR distance=0
add dst-address=10.200.100.36/30 gateway=MSK-DC distance=0
add dst-address=10.200.100.52/30 gateway=NSK-SERVHOST distance=0
add dst-address=10.205.1.0/30 gateway=wg1 distance=0
add dst-address=62.182.194.0/24 gateway=br1 distance=0
add dst-address=192.168.0.0/16 gateway=10.200.100.13%MSK-VPSVILLE distance=1
/ip firewall filter
add chain=input action=drop protocol=udp dst-port=53
add chain=input action=accept protocol=udp dst-port=13231
@@ -1,44 +0,0 @@
# synthetic export generated by MikrotikManager
# generated-at: 2026-05-07T07:13:54.118Z
/system identity
set name="veesp.swe.rt.shx.su"
/interface
:put "interface name="ether1" mtu=1500 disabled=no"
:put "interface name="MSK-DC" mtu=1350 disabled=no"
:put "interface name="MSK-IHOR" mtu=1400 disabled=no"
:put "interface name="MSK-VPSVILLE" mtu=1400 disabled=no"
:put "interface name="NSK-SERVHOST" mtu=1400 disabled=no"
:put "interface name="br1" mtu=1500 disabled=no"
:put "interface name="gre-tunnel1" mtu=1450 disabled=no"
:put "interface name="lo" mtu=65536 disabled=no"
:put "interface name="wg-tunnel" mtu=1420 disabled=no"
:put "interface name="wg1" mtu=1280 disabled=no"
/ip address
add address=62.182.194.146/24 interface="ether1"
add address=10.200.100.26/30 interface="MSK-IHOR"
add address=10.200.200.2/30 interface="*5"
add address=10.200.40.2/30 interface="MSK-VPSVILLE"
add address=10.205.1.2/30 interface="wg1"
add address=10.200.100.2/30 interface="*11" comment="Interface: MSK-SELECTEL (GRE) to selectel.msk.rt.shx.su"
add address=10.200.100.38/30 interface="MSK-DC" comment="Interface: MSK-DC (GRE) to dc.msk.rt.shx.su"
add address=10.200.100.14/30 interface="MSK-VPSVILLE" comment="Interface: MSK-VPSVILLE (GRE) to vpsville.msk.rt.shx.su"
add address=10.101.1.2/30 interface="gre-tunnel1"
add address=10.200.100.54/30 interface="NSK-SERVHOST" comment="Interface: NSK-SERVHOST (GRE) to servhost.nsk.rt.shx.su"
/ip route
add dst-address=0.0.0.0/0 gateway=62.182.194.1 distance=1
add dst-address=10.101.1.0/30 gateway=gre-tunnel1 distance=0
add dst-address=10.200.100.12/30 gateway=MSK-VPSVILLE distance=0
add dst-address=10.200.100.24/30 gateway=MSK-IHOR distance=0
add dst-address=10.200.100.36/30 gateway=MSK-DC distance=0
add dst-address=10.200.100.52/30 gateway=NSK-SERVHOST distance=0
add dst-address=10.205.1.0/30 gateway=wg1 distance=0
add dst-address=62.182.194.0/24 gateway=br1 distance=0
add dst-address=192.168.0.0/16 gateway=10.200.100.13%MSK-VPSVILLE distance=1
/ip firewall filter
add chain=input action=drop protocol=udp dst-port=53
add chain=input action=accept protocol=udp dst-port=13231
@@ -1,44 +0,0 @@
# synthetic export generated by MikrotikManager
# generated-at: 2026-05-07T07:19:17.424Z
/system identity
set name="veesp.swe.rt.shx.su"
/interface
:put "interface name="ether1" mtu=1500 disabled=no"
:put "interface name="MSK-DC" mtu=1350 disabled=no"
:put "interface name="MSK-IHOR" mtu=1400 disabled=no"
:put "interface name="MSK-VPSVILLE" mtu=1400 disabled=no"
:put "interface name="NSK-SERVHOST" mtu=1400 disabled=no"
:put "interface name="br1" mtu=1500 disabled=no"
:put "interface name="gre-tunnel1" mtu=1450 disabled=no"
:put "interface name="lo" mtu=65536 disabled=no"
:put "interface name="wg-tunnel" mtu=1420 disabled=no"
:put "interface name="wg1" mtu=1280 disabled=no"
/ip address
add address=62.182.194.146/24 interface="ether1"
add address=10.200.100.26/30 interface="MSK-IHOR"
add address=10.200.200.2/30 interface="*5"
add address=10.200.40.2/30 interface="MSK-VPSVILLE"
add address=10.205.1.2/30 interface="wg1"
add address=10.200.100.2/30 interface="*11" comment="Interface: MSK-SELECTEL (GRE) to selectel.msk.rt.shx.su"
add address=10.200.100.38/30 interface="MSK-DC" comment="Interface: MSK-DC (GRE) to dc.msk.rt.shx.su"
add address=10.200.100.14/30 interface="MSK-VPSVILLE" comment="Interface: MSK-VPSVILLE (GRE) to vpsville.msk.rt.shx.su"
add address=10.101.1.2/30 interface="gre-tunnel1"
add address=10.200.100.54/30 interface="NSK-SERVHOST" comment="Interface: NSK-SERVHOST (GRE) to servhost.nsk.rt.shx.su"
/ip route
add dst-address=0.0.0.0/0 gateway=62.182.194.1 distance=1
add dst-address=10.101.1.0/30 gateway=gre-tunnel1 distance=0
add dst-address=10.200.100.12/30 gateway=MSK-VPSVILLE distance=0
add dst-address=10.200.100.24/30 gateway=MSK-IHOR distance=0
add dst-address=10.200.100.36/30 gateway=MSK-DC distance=0
add dst-address=10.200.100.52/30 gateway=NSK-SERVHOST distance=0
add dst-address=10.205.1.0/30 gateway=wg1 distance=0
add dst-address=62.182.194.0/24 gateway=br1 distance=0
add dst-address=192.168.0.0/16 gateway=10.200.100.13%MSK-VPSVILLE distance=1
/ip firewall filter
add chain=input action=drop protocol=udp dst-port=53
add chain=input action=accept protocol=udp dst-port=13231
@@ -1,45 +0,0 @@
# synthetic export generated by MikrotikManager
# generated-at: 2026-05-12T14:44:14.588Z
/system identity
set name="veesp.swe.rt.shx.su"
/interface
:put "interface name="ether1" mtu=1500 disabled=no"
:put "interface name="MSK-DC" mtu=1350 disabled=no"
:put "interface name="MSK-IHOR" mtu=1400 disabled=no"
:put "interface name="MSK-VPSVILLE" mtu=1400 disabled=no"
:put "interface name="NSK-SERVHOST" mtu=1400 disabled=no"
:put "interface name="br1" mtu=1500 disabled=no"
:put "interface name="gre-tunnel1" mtu=1450 disabled=no"
:put "interface name="lo" mtu=65536 disabled=no"
:put "interface name="wg-tunnel" mtu=1420 disabled=no"
:put "interface name="wg1" mtu=1280 disabled=no"
/ip address
add address=62.182.194.146/24 interface="ether1"
add address=10.200.100.26/30 interface="MSK-IHOR"
add address=10.200.200.2/30 interface="*5"
add address=10.200.40.2/30 interface="MSK-VPSVILLE"
add address=10.205.1.2/30 interface="wg1"
add address=10.200.100.2/30 interface="*11" comment="Interface: MSK-SELECTEL (GRE) to selectel.msk.rt.shx.su"
add address=10.200.100.38/30 interface="MSK-DC" comment="Interface: MSK-DC (GRE) to dc.msk.rt.shx.su"
add address=10.200.100.14/30 interface="MSK-VPSVILLE" comment="Interface: MSK-VPSVILLE (GRE) to vpsville.msk.rt.shx.su"
add address=10.101.1.2/30 interface="gre-tunnel1"
add address=10.200.100.54/30 interface="NSK-SERVHOST" comment="Interface: NSK-SERVHOST (GRE) to servhost.nsk.rt.shx.su"
/ip route
add dst-address=192.168.0.0/16 gateway=10.200.100.13%MSK-VPSVILLE distance=1
add dst-address=0.0.0.0/0 gateway=62.182.194.1 distance=1
add dst-address=10.101.1.0/30 gateway=gre-tunnel1 distance=0
add dst-address=10.200.100.12/30 gateway=MSK-VPSVILLE distance=0
add dst-address=10.200.100.24/30 gateway=MSK-IHOR distance=0
add dst-address=10.200.100.36/30 gateway=MSK-DC distance=0
add dst-address=10.200.100.52/30 gateway=NSK-SERVHOST distance=0
add dst-address=10.205.1.0/30 gateway=wg1 distance=0
add dst-address=62.182.194.0/24 gateway=br1 distance=0
add dst-address=192.168.0.0/16 gateway=10.200.100.53 distance=1
/ip firewall filter
add chain=input action=drop protocol=udp dst-port=53
add chain=input action=accept protocol=udp dst-port=13231
@@ -1,45 +0,0 @@
# synthetic export generated by MikrotikManager
# generated-at: 2026-05-12T14:45:07.934Z
/system identity
set name="veesp.swe.rt.shx.su"
/interface
:put "interface name="ether1" mtu=1500 disabled=no"
:put "interface name="MSK-DC" mtu=1350 disabled=no"
:put "interface name="MSK-IHOR" mtu=1400 disabled=no"
:put "interface name="MSK-VPSVILLE" mtu=1400 disabled=no"
:put "interface name="NSK-SERVHOST" mtu=1400 disabled=no"
:put "interface name="br1" mtu=1500 disabled=no"
:put "interface name="gre-tunnel1" mtu=1450 disabled=no"
:put "interface name="lo" mtu=65536 disabled=no"
:put "interface name="wg-tunnel" mtu=1420 disabled=no"
:put "interface name="wg1" mtu=1280 disabled=no"
/ip address
add address=62.182.194.146/24 interface="ether1"
add address=10.200.100.26/30 interface="MSK-IHOR"
add address=10.200.200.2/30 interface="*5"
add address=10.200.40.2/30 interface="MSK-VPSVILLE"
add address=10.205.1.2/30 interface="wg1"
add address=10.200.100.2/30 interface="*11" comment="Interface: MSK-SELECTEL (GRE) to selectel.msk.rt.shx.su"
add address=10.200.100.38/30 interface="MSK-DC" comment="Interface: MSK-DC (GRE) to dc.msk.rt.shx.su"
add address=10.200.100.14/30 interface="MSK-VPSVILLE" comment="Interface: MSK-VPSVILLE (GRE) to vpsville.msk.rt.shx.su"
add address=10.101.1.2/30 interface="gre-tunnel1"
add address=10.200.100.54/30 interface="NSK-SERVHOST" comment="Interface: NSK-SERVHOST (GRE) to servhost.nsk.rt.shx.su"
/ip route
add dst-address=192.168.0.0/16 gateway=10.200.100.13%MSK-VPSVILLE distance=1
add dst-address=0.0.0.0/0 gateway=62.182.194.1 distance=1
add dst-address=10.101.1.0/30 gateway=gre-tunnel1 distance=0
add dst-address=10.200.100.12/30 gateway=MSK-VPSVILLE distance=0
add dst-address=10.200.100.24/30 gateway=MSK-IHOR distance=0
add dst-address=10.200.100.36/30 gateway=MSK-DC distance=0
add dst-address=10.200.100.52/30 gateway=NSK-SERVHOST distance=0
add dst-address=10.205.1.0/30 gateway=wg1 distance=0
add dst-address=62.182.194.0/24 gateway=br1 distance=0
add dst-address=192.168.0.0/16 gateway=10.200.100.53 distance=1
/ip firewall filter
add chain=input action=drop protocol=udp dst-port=53
add chain=input action=accept protocol=udp dst-port=13231
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -21,5 +21,7 @@
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
"registries": {
"@reui": "https://reui.io/r/{style}/{name}.json"
}
}
+171
View File
@@ -0,0 +1,171 @@
"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 {
DataGrid,
DataGridColumnHeader,
DataGridContainer,
DataGridTable,
} from "@/components/reui/data-grid"
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 }) => <DataGridColumnHeader column={column} title="Файл" />,
cell: ({ row }) => (
<span className="font-mono text-xs font-medium">{row.original.filename}</span>
),
},
{
id: "server",
accessorKey: "server",
header: ({ column }) => <DataGridColumnHeader column={column} title="Сервер" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">{row.original.server}</span>
),
},
{
id: "size",
accessorKey: "size",
header: ({ column }) => <DataGridColumnHeader column={column} title="Размер" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.size}</span>
),
},
{
id: "kind",
accessorKey: "kind",
header: ({ column }) => <DataGridColumnHeader 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>
)
},
},
{
id: "notes",
accessorKey: "notes",
header: ({ column }) => <DataGridColumnHeader column={column} title="Заметки" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground max-w-[200px] truncate block">
{row.original.notes || "—"}
</span>
),
},
{
id: "created",
accessorKey: "created",
header: ({ column }) => <DataGridColumnHeader column={column} title="Создан" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">{row.original.created}</span>
),
},
{
id: "actions",
header: () => null,
cell: ({ row }) => {
const b = row.original
return (
<div className="flex items-center gap-1 justify-end">
<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,
},
],
[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 (
<DataGrid table={table} recordCount={backups.length} isLoading={false}>
<DataGridContainer>
<DataGridTable />
</DataGridContainer>
</DataGrid>
)
}
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,253 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import {
DataGrid,
DataGridColumnHeader,
DataGridContainer,
DataGridTable,
} from "@/components/reui/data-grid"
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 }) => <DataGridColumnHeader column={column} title="Роутер" />,
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: "Роутер",
expandedContent: (row: BgpSessionRow) => <BgpSessionDetail session={row} />,
},
},
{
id: "peerIp",
accessorKey: "peerIp",
header: ({ column }) => <DataGridColumnHeader column={column} title="Peer IP" />,
cell: ({ row }) => <span className="font-mono">{row.original.peerIp}</span>,
},
{
id: "remoteAs",
accessorKey: "remoteAs",
header: ({ column }) => <DataGridColumnHeader 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>
)
},
},
{
id: "description",
accessorKey: "description",
header: ({ column }) => <DataGridColumnHeader column={column} title="Описание" />,
cell: ({ row }) => (
<span className="text-muted-foreground max-w-[180px] truncate block">
{row.original.description}
</span>
),
},
{
id: "type",
accessorKey: "type",
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
cell: ({ row }) => <TypeBadge type={row.original.type} />,
},
{
id: "state",
accessorKey: "state",
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => <StateBadge state={row.original.state} />,
},
{
id: "uptime",
accessorKey: "uptime",
header: ({ column }) => <DataGridColumnHeader column={column} title="Uptime" />,
cell: ({ row }) => (
<span className="font-mono tabular-nums text-muted-foreground">
{row.original.uptime ?? "—"}
</span>
),
},
{
id: "prefixesRx",
accessorKey: "prefixesRx",
header: ({ column }) => <DataGridColumnHeader 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>
)
},
},
{
id: "prefixesTx",
accessorKey: "prefixesTx",
header: ({ column }) => <DataGridColumnHeader 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>
)
},
},
],
[],
)
const table = useReactTable({
data: sessions,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowId: (row) => row.id,
})
if (sessions.length === 0) {
return (
<EmptyState
icon={<NetworkIcon className="size-4" />}
title="Нет BGP-сессий"
description="Измените фильтры или проверьте подключение к роутерам"
className="border-0 py-10"
/>
)
}
return (
<DataGrid
table={table}
recordCount={sessions.length}
isLoading={false}
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
>
<DataGridContainer>
<DataGridTable />
</DataGridContainer>
</DataGrid>
)
}
export { BgpSessionsDataGrid, type BgpSessionsDataGridProps }
@@ -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 }
+452
View File
@@ -0,0 +1,452 @@
"use client"
import { useMemo } from "react"
import {
type Column,
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 {
DataGrid,
DataGridContainer,
DataGridTable,
} from "@/components/reui/data-grid"
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,
ArrowUpIcon,
ArrowDownIcon,
ArrowUpDownIcon,
} 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",
}
const CELL_PAD = "py-3"
const CELL_PAD_FIRST = "pl-5 py-3"
const CELL_PAD_LAST = "pr-4 py-3"
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>
}
function ServersTableHeader<TData>({
column,
title,
className,
}: {
column: Column<TData, unknown>
title: string
className?: string
}) {
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>
)
}
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 }) => (
<ServersTableHeader 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: CELL_PAD_FIRST,
cellClassName: 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 }) => <ServersTableHeader column={column} title="Тип" />,
cell: ({ row }) => <TypeBadge type={row.original.type} />,
meta: { headerTitle: "Тип", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
},
{
id: "model",
accessorKey: "model",
header: ({ column }) => <ServersTableHeader column={column} title="Модель" />,
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.model}</span>,
meta: { headerTitle: "Модель", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
},
{
id: "os",
accessorKey: "os",
header: ({ column }) => <ServersTableHeader column={column} title="RouterOS" />,
cell: ({ row }) => <RosBadge os={row.original.os} />,
meta: { headerTitle: "RouterOS", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
},
{
id: "site",
accessorKey: "site",
header: ({ column }) => <ServersTableHeader 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: CELL_PAD, cellClassName: 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: CELL_PAD, cellClassName: CELL_PAD },
},
{
id: "latency",
accessorKey: "latency",
header: ({ column }) => (
<ServersTableHeader 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(CELL_PAD, "text-right"),
cellClassName: cn(CELL_PAD, "text-right"),
},
},
{
id: "status",
accessorKey: "status",
header: ({ column }) => <ServersTableHeader column={column} title="Статус" />,
cell: ({ row }) => <StatusBadge status={row.original.status} />,
meta: { headerTitle: "Статус", headerClassName: CELL_PAD, cellClassName: 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: CELL_PAD_LAST,
cellClassName: 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 (
<DataGrid
table={table}
recordCount={servers.length}
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
tableLayout={{
rowBorder: true,
headerBackground: true,
headerBorder: true,
columnsResizable: false,
}}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: "group/row",
}}
>
<DataGridContainer border={false} className="rounded-none border-0">
<DataGridTable />
</DataGridContainer>
</DataGrid>
)
}
export { ServersDataGrid, rosVer, RosBadge, TypeBadge }
+79
View File
@@ -0,0 +1,79 @@
"use client"
import { ReactNode } from "react"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"
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 && (
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<Input
className="h-6 border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
placeholder={searchPlaceholder}
value={search ?? ""}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
)}
{countLabel && (
<span className="text-sm text-muted-foreground ml-auto">{countLabel}</span>
)}
{actions}
</div>
)
}
export { DataPageToolbar, type DataPageToolbarProps }
+76 -37
View File
@@ -1,8 +1,24 @@
"use client"
import { useMemo, useState } from "react"
import {
type ColumnDef,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { Card } from "@/components/ui/card"
import { SearchIcon } from "lucide-react"
import {
DataGrid,
DataGridContainer,
DataGridPagination,
DataGridTable,
} from "@/components/reui/data-grid"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { EmptyState } from "@/components/empty-state"
import { InboxIcon } from "lucide-react"
export interface Column<T> {
key: string
@@ -15,6 +31,9 @@ interface DataTableProps<T extends { id: string }> {
columns: Column<T>[]
searchPlaceholder?: string
searchKeys?: (keyof T)[]
isLoading?: boolean
emptyTitle?: string
emptyDescription?: string
}
export function DataTable<T extends { id: string }>({
@@ -22,10 +41,14 @@ export function DataTable<T extends { id: string }>({
columns,
searchPlaceholder = "Поиск…",
searchKeys = [],
isLoading = false,
emptyTitle = "Нет записей",
emptyDescription,
}: DataTableProps<T>) {
const [search, setSearch] = useState("")
const [globalFilter, setGlobalFilter] = useState("")
const filtered = useMemo(() => {
const filteredData = useMemo(() => {
if (!search || searchKeys.length === 0) return data
const s = search.toLowerCase()
return data.filter((row) =>
@@ -33,44 +56,60 @@ export function DataTable<T extends { id: string }>({
)
}, [data, search, searchKeys])
const columnDefs = useMemo<ColumnDef<T>[]>(
() =>
columns.map((col) => ({
id: col.key,
accessorKey: col.key,
header: col.label,
cell: ({ row }) => col.render(row.original),
meta: { headerTitle: col.label },
})),
[columns],
)
const table = useReactTable({
data: filteredData,
columns: columnDefs,
state: { globalFilter },
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row) => row.id,
})
const displayCount = searchKeys.length > 0 ? filteredData.length : data.length
return (
<Card>
<div className="flex items-center gap-2 px-5 py-3 border-b">
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder={searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={searchPlaceholder}
countLabel={`${displayCount} записей`}
/>
<DataGrid
table={table}
recordCount={filteredData.length}
isLoading={isLoading}
loadingMode="skeleton"
emptyMessage={
<EmptyState
icon={<InboxIcon className="size-4" />}
title={emptyTitle}
description={emptyDescription}
className="py-12"
/>
</div>
<span className="text-sm text-muted-foreground ml-1">{filtered.length} записей</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
{columns.map((col) => (
<th key={col.key} className="text-left font-medium px-5 py-3">
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((row) => (
<tr key={row.id} className="hover:bg-muted/40 transition-colors">
{columns.map((col) => (
<td key={col.key} className="px-5 py-3">
{col.render(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
}
tableLayout={{ rowBorder: true, headerBackground: true }}
>
<DataGridContainer border={false}>
<DataGridTable />
</DataGridContainer>
{filteredData.length > 0 && <DataGridPagination className="px-5 pb-3" />}
</DataGrid>
</Card>
)
}
+32
View File
@@ -0,0 +1,32 @@
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import { cn } from "@/lib/utils"
interface EmptyStateProps {
icon?: React.ReactNode
title: string
description?: string
action?: React.ReactNode
className?: string
}
function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
return (
<Empty className={cn("border-0 py-16", className)}>
<EmptyHeader>
{icon && <EmptyMedia variant="icon">{icon}</EmptyMedia>}
<EmptyTitle>{title}</EmptyTitle>
{description && <EmptyDescription>{description}</EmptyDescription>}
</EmptyHeader>
{action && <EmptyContent>{action}</EmptyContent>}
</Empty>
)
}
export { EmptyState, type EmptyStateProps }
+120
View File
@@ -0,0 +1,120 @@
"use client"
import { useCallback, useState } from "react"
import { UploadIcon } from "lucide-react"
import { toast } from "sonner"
import { useFileUpload } from "@/hooks/use-file-upload"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
interface FileImportDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
accept?: string
multiple?: boolean
onImport: (files: File[]) => void | Promise<void>
}
function FileImportDialog({
open,
onOpenChange,
title,
description,
accept = "*",
multiple = false,
onImport,
}: FileImportDialogProps) {
const [importing, setImporting] = useState(false)
const [{ files, isDragging, errors }, actions] = useFileUpload({
accept,
multiple,
maxFiles: multiple ? 10 : 1,
onError: (errs) => {
errs.forEach((e) => toast.error(e))
},
})
const handleImport = useCallback(async () => {
const rawFiles = files
.map((f) => (f.file instanceof File ? f.file : null))
.filter((f): f is File => f != null)
if (rawFiles.length === 0) {
toast.error("Выберите файл для импорта")
return
}
setImporting(true)
try {
await onImport(rawFiles)
actions.clearFiles()
onOpenChange(false)
toast.success("Импорт выполнен")
} catch (err) {
toast.error(err instanceof Error ? err.message : "Ошибка импорта")
} finally {
setImporting(false)
}
}, [actions, files, onImport, onOpenChange])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<div
className={cn(
"flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center transition-colors",
isDragging ? "border-primary bg-primary/5" : "border-border",
)}
onDragEnter={actions.handleDragEnter}
onDragLeave={actions.handleDragLeave}
onDragOver={actions.handleDragOver}
onDrop={actions.handleDrop}
>
<UploadIcon className="size-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">
Перетащите файл сюда или выберите на диске
</p>
<Button type="button" variant="outline" size="sm" onClick={actions.openFileDialog}>
Выбрать файл
</Button>
<input {...actions.getInputProps()} className="sr-only" />
{files.length > 0 && (
<ul className="w-full text-left text-sm">
{files.map((f) => (
<li key={f.id} className="truncate font-mono text-xs">
{f.file instanceof File ? f.file.name : f.file.name}
</li>
))}
</ul>
)}
{errors.map((e) => (
<p key={e} className="text-xs text-destructive">{e}</p>
))}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button type="button" onClick={handleImport} disabled={importing || files.length === 0}>
{importing ? "Импорт…" : "Импортировать"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export { FileImportDialog, type FileImportDialogProps }
+31
View File
@@ -0,0 +1,31 @@
"use client"
import {
Field,
FieldDescription,
FieldLabel,
} from "@/components/ui/field"
interface FormFieldProps {
label: string
hint?: string
error?: string
required?: boolean
children: React.ReactNode
}
function FormField({ label, hint, error, required, children }: FormFieldProps) {
return (
<Field data-invalid={!!error}>
<FieldLabel>
{label}
{required && <span className="text-destructive ml-0.5">*</span>}
</FieldLabel>
{children}
{hint && !error && <FieldDescription>{hint}</FieldDescription>}
{error && <FieldDescription className="text-destructive">{error}</FieldDescription>}
</Field>
)
}
export { FormField, type FormFieldProps }
+24
View File
@@ -0,0 +1,24 @@
"use client"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
interface FormToggleProps {
checked: boolean
onChange: (value: boolean) => void
disabled?: boolean
className?: string
}
function FormToggle({ checked, onChange, disabled, className }: FormToggleProps) {
return (
<Switch
checked={checked}
onCheckedChange={onChange}
disabled={disabled}
className={cn(className)}
/>
)
}
export { FormToggle, type FormToggleProps }
+4
View File
@@ -0,0 +1,4 @@
export { FormField, type FormFieldProps } from "./form-field"
export { FormToggle, type FormToggleProps } from "./form-toggle"
export { SectionTitle, type SectionTitleProps } from "./section-title"
export { SegmentedControl, type SegmentedControlProps } from "./segmented-control"
+21
View File
@@ -0,0 +1,21 @@
import { cn } from "@/lib/utils"
interface SectionTitleProps {
children: React.ReactNode
icon?: React.ReactNode
className?: string
}
function SectionTitle({ children, icon, className }: SectionTitleProps) {
return (
<div className={cn("flex items-center gap-2 py-0.5", className)}>
{icon}
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{children}
</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
export { SectionTitle, type SectionTitleProps }
+47
View File
@@ -0,0 +1,47 @@
"use client"
import { cn } from "@/lib/utils"
interface SegmentedControlProps<T extends string> {
value: T
onChange: (value: T) => void
options: { value: T; label: string; count?: number }[]
className?: string
}
function SegmentedControl<T extends string>({
value,
onChange,
options,
className,
}: SegmentedControlProps<T>) {
return (
<div
className={cn(
"flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit",
className,
)}
>
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
"flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors",
value === option.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
{option.count != null && (
<span className="text-xs tabular-nums opacity-60">{option.count}</span>
)}
</button>
))}
</div>
)
}
export { SegmentedControl, type SegmentedControlProps }
+92
View File
@@ -0,0 +1,92 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
[
"relative w-full text-sm border has-[>svg]:grid-cols-[calc(var(--spacing)*3)_1fr] grid-cols-[0_1fr] grid gap-y-0.5 items-center [&>svg:not([class*=size-])]:size-4",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_[data-slot=alert-action]]:sm:row-end-3",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:items-start",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_svg]:translate-y-0.5",
"rounded-lg",
"px-3",
"py-2.5",
"has-[>svg]:gap-x-2.5",
],
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"border-destructive/30 bg-destructive/4 [&>svg]:text-destructive",
info: "border-info/30 bg-info/4 [&>svg]:text-info",
success: "border-success/30 bg-success/4 [&>svg]:text-success",
warning: "border-warning/30 bg-warning/4 [&>svg]:text-warning",
invert:
"border-invert bg-invert text-invert-foreground [&_[data-slot=alert-description]]:text-invert-foreground/70",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn(
"flex gap-1.5 max-sm:col-start-2 max-sm:mt-2 max-sm:justify-start sm:col-start-3 sm:row-start-1 sm:justify-end sm:self-center",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
+343
View File
@@ -0,0 +1,343 @@
"use client"
import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { ScrollArea } from "@/components/ui/scroll-area"
import { XIcon, ChevronsUpDownIcon } from "lucide-react"
const inputVariants = cva(
"outline-none flex w-full text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [[readonly]]:bg-muted/80 [[readonly]]:cursor-not-allowed border border-input focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg bg-transparent dark:bg-input/30 text-sm transition-colors focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-3",
{
variants: {
size: {
sm: "h-7 px-2 [&~[data-slot=autocomplete-clear]]:end-1.5 [&~[data-slot=autocomplete-trigger]]:end-1.5",
default:
"h-8 px-2.5 [&~[data-slot=autocomplete-clear]]:end-1.75 [&~[data-slot=autocomplete-trigger]]:end-1.75",
lg: "h-9 px-2.5 [&~[data-slot=autocomplete-clear]]:end-2 [&~[data-slot=autocomplete-trigger]]:end-2",
},
},
defaultVariants: {
size: "default",
},
}
)
const Autocomplete = AutocompletePrimitive.Root
function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
return (
<AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />
)
}
function AutocompleteInput({
className,
size = "default",
showClear = false,
showTrigger = false,
...props
}: Omit<AutocompletePrimitive.Input.Props, "size"> &
VariantProps<typeof inputVariants> & {
showClear?: boolean
showTrigger?: boolean
}) {
return (
<div className="relative w-full">
<AutocompletePrimitive.Input
data-slot="autocomplete-input"
data-size={size}
className={cn(inputVariants({ size }), className)}
{...props}
/>
{showTrigger && <AutocompleteTrigger />}
{showClear && <AutocompleteClear />}
</div>
)
}
function AutocompleteStatus({
className,
...props
}: AutocompletePrimitive.Status.Props) {
return (
<AutocompletePrimitive.Status
data-slot="autocomplete-status"
className={cn(
"text-muted-foreground px-2 py-1.5 text-sm empty:m-0 empty:p-0",
className
)}
{...props}
/>
)
}
function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) {
return (
<AutocompletePrimitive.Portal data-slot="autocomplete-portal" {...props} />
)
}
function AutocompleteBackdrop({
...props
}: AutocompletePrimitive.Backdrop.Props) {
return (
<AutocompletePrimitive.Backdrop
data-slot="autocomplete-backdrop"
{...props}
/>
)
}
function AutocompletePositioner({
className,
...props
}: AutocompletePrimitive.Positioner.Props) {
return (
<AutocompletePrimitive.Positioner
data-slot="autocomplete-positioner"
className={cn("z-50 outline-none", className)}
{...props}
/>
)
}
function AutocompleteList({
className,
scrollAreaClassName,
...props
}: AutocompletePrimitive.List.Props & {
scrollAreaClassName?: string
scrollFade?: boolean
scrollbarGutter?: boolean
}) {
return (
<ScrollArea
className={cn(
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain",
scrollAreaClassName
)}
>
<AutocompletePrimitive.List
data-slot="autocomplete-list"
className={cn(
"not-empty:px-1 not-empty:py-1 not-empty:scroll-py-1 in-data-has-overflow-y:me-3",
className
)}
{...props}
/>
</ScrollArea>
)
}
function AutocompleteCollection({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Collection>) {
return (
<AutocompletePrimitive.Collection
data-slot="autocomplete-collection"
{...props}
/>
)
}
function AutocompleteRow({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Row>) {
return (
<AutocompletePrimitive.Row
data-slot="autocomplete-row"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function AutocompleteItem({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Item>) {
return (
<AutocompletePrimitive.Item
data-slot="autocomplete-item"
className={cn(
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5 rounded-md px-1.5 py-1 text-sm data-highlighted:before:rounded-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
className
)}
{...props}
/>
)
}
export interface AutocompleteContentProps extends React.ComponentProps<
typeof AutocompletePrimitive.Popup
> {
align?: AutocompletePrimitive.Positioner.Props["align"]
sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"]
alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"]
side?: AutocompletePrimitive.Positioner.Props["side"]
anchor?: AutocompletePrimitive.Positioner.Props["anchor"]
showBackdrop?: boolean
}
function AutocompleteContent({
className,
children,
showBackdrop = false,
align = "start",
sideOffset = 4,
alignOffset = 0,
side = "bottom",
anchor,
...props
}: AutocompleteContentProps) {
return (
<AutocompletePortal>
{showBackdrop && <AutocompleteBackdrop />}
<AutocompletePositioner
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
side={side}
anchor={anchor}
>
<div className="relative flex max-h-full">
<AutocompletePrimitive.Popup
data-slot="autocomplete-popup"
className={cn(
"bg-popover text-popover-foreground rounded-lg shadow-md ring-foreground/10 flex max-h-[min(var(--available-height),24rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) scroll-pt-2 scroll-pb-2 flex-col overscroll-contain py-0.5 ring-1 transition-[scale,opacity] has-data-starting-style:scale-98 has-data-starting-style:opacity-0 has-data-[side=none]:scale-100 has-data-[side=none]:transition-none",
className
)}
{...props}
>
{children}
</AutocompletePrimitive.Popup>
</div>
</AutocompletePositioner>
</AutocompletePortal>
)
}
function AutocompleteGroup({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Group>) {
return (
<AutocompletePrimitive.Group data-slot="autocomplete-group" {...props} />
)
}
function AutocompleteGroupLabel({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.GroupLabel>) {
return (
<AutocompletePrimitive.GroupLabel
data-slot="autocomplete-group-label"
className={cn(
"text-muted-foreground px-1.5 py-1 text-xs font-medium",
className
)}
{...props}
/>
)
}
function AutocompleteEmpty({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Empty>) {
return (
<AutocompletePrimitive.Empty
data-slot="autocomplete-empty"
className={cn(
"text-muted-foreground px-2 py-1.5 text-sm text-center empty:m-0 empty:p-0",
className
)}
{...props}
/>
)
}
function AutocompleteClear({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Clear>) {
return (
<AutocompletePrimitive.Clear
data-slot="autocomplete-clear"
className={cn(
"ring-offset-background focus:ring-ring absolute top-1/2 -translate-y-1/2 cursor-pointer opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-disabled:pointer-events-none",
className
)}
{...props}
>
<XIcon className="size-4" />
</AutocompletePrimitive.Clear>
)
}
function AutocompleteTrigger({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Trigger>) {
return (
<AutocompletePrimitive.Trigger
data-slot="autocomplete-trigger"
className={cn(
"focus:ring-ring ring-offset-background absolute top-1/2 -translate-y-1/2 cursor-pointer focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none has-[+[data-slot=autocomplete-clear]]:hidden data-disabled:pointer-events-none",
className
)}
{...props}
>
<ChevronsUpDownIcon className="size-4 opacity-70" />
</AutocompletePrimitive.Trigger>
)
}
function AutocompleteArrow({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Arrow>) {
return (
<AutocompletePrimitive.Arrow data-slot="autocomplete-arrow" {...props} />
)
}
function AutocompleteSeparator({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Separator>) {
return (
<AutocompletePrimitive.Separator
data-slot="autocomplete-separator"
className={cn(
"bg-border my-1.5 h-px",
className
)}
{...props}
/>
)
}
export {
Autocomplete,
AutocompleteValue,
AutocompleteTrigger,
AutocompleteInput,
AutocompleteStatus,
AutocompletePortal,
AutocompleteBackdrop,
AutocompletePositioner,
AutocompleteContent,
AutocompleteList,
AutocompleteCollection,
AutocompleteRow,
AutocompleteItem,
AutocompleteGroup,
AutocompleteGroupLabel,
AutocompleteEmpty,
AutocompleteClear,
AutocompleteArrow,
AutocompleteSeparator,
}
+98
View File
@@ -0,0 +1,98 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground",
outline: "border-border bg-transparent dark:bg-input/32",
secondary: "bg-secondary text-secondary-foreground",
info: "bg-info text-white",
success: "bg-success text-white",
warning: "bg-warning text-white",
destructive: "bg-destructive text-white",
focus: "bg-focus text-focus-foreground",
invert: "bg-invert text-invert-foreground",
"primary-light":
"bg-primary/10 border-none text-primary dark:bg-primary/20",
"warning-light":
"bg-warning/10 border-none text-warning-foreground dark:bg-warning/20",
"success-light":
"bg-success/10 border-none text-success-foreground dark:bg-success/20",
"info-light":
"bg-info/10 border-none text-info-foreground dark:bg-info/20",
"destructive-light":
"bg-destructive/10 border-none text-destructive-foreground dark:bg-destructive/20",
"invert-light":
"bg-invert/10 border-none text-foreground dark:bg-invert/20",
"focus-light":
"bg-focus/10 border-none text-focus-foreground dark:bg-focus/20",
"primary-outline":
"bg-background border-border text-primary dark:bg-input/30",
"warning-outline":
"bg-background border-border text-warning-foreground dark:bg-input/30",
"success-outline":
"bg-background border-border text-success-foreground dark:bg-input/30",
"info-outline":
"bg-background border-border text-info-foreground dark:bg-input/30",
"destructive-outline":
"bg-background border-border text-destructive-foreground dark:bg-input/30",
"invert-outline":
"bg-background border-border text-invert-foreground dark:bg-input/30",
"focus-outline":
"bg-background border-border text-focus-foreground dark:bg-input/30",
},
size: {
xs: "px-1 py-0.25 text-[0.6rem] leading-none h-4 min-w-4 gap-1",
sm: "px-1 py-0.25 text-[0.625rem] leading-none h-4.5 min-w-4.5 gap-1",
default: "px-1.25 py-0.5 text-xs h-5 min-w-5 gap-1",
lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1",
xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5",
},
/** `default`: per-theme radius. `full`: max radius per theme (Lyra stays `rounded-none`). */
radius: {
default:
"rounded-sm",
full: "rounded-full",
},
},
defaultVariants: {
variant: "default",
size: "default",
radius: "default",
},
}
)
interface BadgeProps extends useRender.ComponentProps<"span"> {
variant?: VariantProps<typeof badgeVariants>["variant"]
size?: VariantProps<typeof badgeVariants>["size"]
radius?: VariantProps<typeof badgeVariants>["radius"]
}
function Badge({
className,
variant,
size,
radius,
render,
...props
}: BadgeProps) {
const defaultProps = {
"data-slot": "badge",
className: cn(badgeVariants({ variant, size, radius, className })),
}
return useRender({
defaultTagName: "span",
render,
props: mergeProps<"span">(defaultProps, props),
})
}
export { Badge, badgeVariants, type BadgeProps }
@@ -0,0 +1,167 @@
"use client"
import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { Column } from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Separator } from "@/components/ui/separator"
import { CirclePlusIcon, CheckIcon } from "lucide-react"
interface DataGridColumnFilterProps<TData, TValue> {
column?: Column<TData, TValue>
title?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
}
function DataGridColumnFilter<TData, TValue>({
column,
title,
options,
}: DataGridColumnFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues()
const selectedValues = new Set(column?.getFilterValue() as string[])
const [searchQuery, setSearchQuery] = useState("")
const filteredOptions = useMemo(() => {
if (!searchQuery) return options
return options.filter((option) =>
option.label.toLowerCase().includes(searchQuery.toLowerCase())
)
}, [options, searchQuery])
return (
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm">
<CirclePlusIcon className="size-4" />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal"
>
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="secondary"
key={option.value}
className="rounded-sm px-1 font-normal"
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
}
/>
<PopoverContent className="w-[200px] p-0" align="start">
<div className="p-2">
<Input
placeholder={title}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8"
/>
</div>
<div className="max-h-[300px] overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground py-6 text-center text-sm">
No results found.
</div>
) : (
<div className="p-1">
{filteredOptions.map((option) => {
const isSelected = selectedValues.has(option.value)
return (
<div
key={option.value}
onClick={() => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
)}
>
<div
className={cn(
"border-primary me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
{option.icon && (
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
)}
<span>{option.label}</span>
{facets?.get(option.value) && (
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
</span>
)}
</div>
)
})}
</div>
)}
{selectedValues.size > 0 && (
<>
<div className="bg-border -mx-1 my-1 h-px" />
<div className="p-1">
<div
onClick={() => column?.setFilterValue(undefined)}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
>
Clear filters
</div>
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
)
}
export { DataGridColumnFilter, type DataGridColumnFilterProps }
@@ -0,0 +1,345 @@
"use client"
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
import {
getColumnHeaderLabel,
useDataGrid,
} from "@/components/reui/data-grid/data-grid"
import { Column } from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
interface DataGridColumnHeaderProps<
TData,
TValue,
> extends HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
title?: string
icon?: ReactNode
pinnable?: boolean
filter?: ReactNode
visibility?: boolean
}
function DataGridColumnHeaderInner<TData, TValue>({
column,
title,
icon,
className,
filter,
visibility = false,
}: DataGridColumnHeaderProps<TData, TValue>) {
const { isLoading, table, props, recordCount } = useDataGrid()
const resolvedTitle = title ?? getColumnHeaderLabel(column)
const columnOrder = table.getState().columnOrder
const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility)
const isSorted = column.getIsSorted()
const isPinned = column.getIsPinned()
const canSort = column.getCanSort()
const canPin = column.getCanPin()
const canResize = column.getCanResize()
const columnIndex = columnOrder.indexOf(column.id)
const canMoveLeft = columnIndex > 0
const canMoveRight = columnIndex < columnOrder.length - 1
const handleSort = () => {
if (isSorted === "asc") {
column.toggleSorting(true)
} else if (isSorted === "desc") {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}
const headerLabelClassName = cn(
"text-secondary-foreground/80 inline-flex h-full items-center gap-1.5 font-normal [&_svg]:opacity-60 text-[0.8125rem] leading-[calc(1.125/0.8125)] [&_svg]:size-3.5",
className
)
const headerButtonClassName = cn(
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg",
className
)
const sortIcon =
canSort &&
(isSorted === "desc" ? (
<ArrowDownIcon className="size-3.25" />
) : isSorted === "asc" ? (
<ArrowUpIcon className="size-3.25" />
) : (
<ChevronsUpDownIcon className="mt-px size-3.25" />
))
const hasControls =
props.tableLayout?.columnsMovable ||
(props.tableLayout?.columnsVisibility && visibility) ||
(props.tableLayout?.columnsPinnable && canPin) ||
filter
const menuItems = useMemo(() => {
const items: ReactNode[] = []
let hasPreviousSection = false
// Filter section
if (filter) {
items.push(
<DropdownMenuGroup key="group-filter">
<DropdownMenuLabel key="filter">{filter}</DropdownMenuLabel>
</DropdownMenuGroup>
)
hasPreviousSection = true
}
// Sort section
if (canSort) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-sort" />)
}
items.push(
<DropdownMenuItem
key="sort-asc"
onClick={() => {
if (isSorted === "asc") {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}}
disabled={!canSort}
>
<ArrowUpIcon className="size-3.5!" />
<span className="grow">Asc</span>
{isSorted === "asc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>,
<DropdownMenuItem
key="sort-desc"
onClick={() => {
if (isSorted === "desc") {
column.clearSorting()
} else {
column.toggleSorting(true)
}
}}
disabled={!canSort}
>
<ArrowDownIcon className="size-3.5!" />
<span className="grow">Desc</span>
{isSorted === "desc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Pin section
if (props.tableLayout?.columnsPinnable && canPin) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-pin" />)
}
items.push(
<DropdownMenuItem
key="pin-left"
onClick={() => column.pin(isPinned === "left" ? false : "left")}
>
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to left</span>
{isPinned === "left" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>,
<DropdownMenuItem
key="pin-right"
onClick={() => column.pin(isPinned === "right" ? false : "right")}
>
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to right</span>
{isPinned === "right" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Move section
if (props.tableLayout?.columnsMovable) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-move" />)
}
items.push(
<DropdownMenuItem
key="move-left"
onClick={() => {
if (columnIndex > 0) {
const newOrder = [...columnOrder]
const [movedColumn] = newOrder.splice(columnIndex, 1)
newOrder.splice(columnIndex - 1, 0, movedColumn)
table.setColumnOrder(newOrder)
}
}}
disabled={!canMoveLeft || isPinned !== false}
>
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Left</span>
</DropdownMenuItem>,
<DropdownMenuItem
key="move-right"
onClick={() => {
if (columnIndex < columnOrder.length - 1) {
const newOrder = [...columnOrder]
const [movedColumn] = newOrder.splice(columnIndex, 1)
newOrder.splice(columnIndex + 1, 0, movedColumn)
table.setColumnOrder(newOrder)
}
}}
disabled={!canMoveRight || isPinned !== false}
>
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Right</span>
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Visibility section
if (props.tableLayout?.columnsVisibility && visibility) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-visibility" />)
}
items.push(
<DropdownMenuSub key="visibility">
<DropdownMenuSubTrigger>
<Settings2Icon className="size-3.5!" />
<span>Columns</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent side="right">
{table
.getAllColumns()
.filter((col) => col.getCanHide())
.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={col.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => col.toggleVisibility(!!value)}
className="capitalize"
>
{getColumnHeaderLabel(col)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
return items
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
filter,
canSort,
isSorted,
column,
props.tableLayout?.columnsPinnable,
props.tableLayout?.columnsMovable,
props.tableLayout?.columnsVisibility,
canPin,
isPinned,
canMoveLeft,
canMoveRight,
visibility,
table,
columnIndex,
columnOrder,
columnVisibilityKey, // Needed to update checkbox states when visibility changes
])
if (hasControls) {
return (
<div className="flex h-full items-center justify-between gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
>
{icon && icon}
{resolvedTitle}
{sortIcon}
</Button>
}
/>
<DropdownMenuContent className="w-40" align="start">
{menuItems}
</DropdownMenuContent>
</DropdownMenu>
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
<Button
size="icon-sm"
variant="ghost"
className="-me-1 size-7 rounded-md"
onClick={() => column.pin(false)}
aria-label={`Unpin ${resolvedTitle} column`}
title={`Unpin ${resolvedTitle} column`}
>
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
</Button>
)}
</div>
)
}
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
return (
<div className="flex h-full items-center">
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
onClick={handleSort}
>
{icon && icon}
{resolvedTitle}
{sortIcon}
</Button>
</div>
)
}
return (
<div className={headerLabelClassName}>
{icon && icon}
{resolvedTitle}
</div>
)
}
const DataGridColumnHeader = memo(
DataGridColumnHeaderInner
) as typeof DataGridColumnHeaderInner
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
@@ -0,0 +1,53 @@
"use client"
import { ReactElement } from "react"
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
import { Table } from "@tanstack/react-table"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
function DataGridColumnVisibility<TData>({
table,
trigger,
}: {
table: Table<TData>
trigger: ReactElement<Record<string, unknown>>
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
<DropdownMenuContent align="end" className="min-w-[150px]">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-medium">
Toggle Columns
</DropdownMenuLabel>
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{getColumnHeaderLabel(column)}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export { DataGridColumnVisibility }
@@ -0,0 +1,226 @@
"use client"
import React, { ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
interface DataGridPaginationProps {
sizes?: number[]
sizesInfo?: string
sizesLabel?: string
sizesDescription?: string
sizesSkeleton?: ReactNode
more?: boolean
moreLimit?: number
info?: string
infoSkeleton?: ReactNode
className?: string
rowsPerPageLabel?: string
previousPageLabel?: string
nextPageLabel?: string
ellipsisText?: string
}
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
const { table, recordCount, isLoading } = useDataGrid()
const defaultProps: Partial<DataGridPaginationProps> = {
sizes: [5, 10, 25, 50, 100],
sizesLabel: "Show",
sizesDescription: "per page",
sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5,
more: false,
info: "{from} - {to} of {count}",
infoSkeleton: <Skeleton className="h-8 w-60" />,
rowsPerPageLabel: "Rows per page",
previousPageLabel: "Go to previous page",
nextPageLabel: "Go to next page",
ellipsisText: "...",
}
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
const btnBaseClasses = "size-7 p-0 text-sm"
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
const pageIndex = table.getState().pagination.pageIndex
const pageSize = table.getState().pagination.pageSize
const from = pageIndex * pageSize + 1
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
const pageCount = table.getPageCount()
// Replace placeholders in paginationInfo
const paginationInfo = mergedProps?.info
? mergedProps.info
.replace("{from}", from.toString())
.replace("{to}", to.toString())
.replace("{count}", recordCount.toString())
: `${from} - ${to} of ${recordCount}`
// Pagination limit logic
const paginationMoreLimit = mergedProps?.moreLimit || 5
// Determine the start and end of the pagination group
const currentGroupStart =
Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit
const currentGroupEnd = Math.min(
currentGroupStart + paginationMoreLimit,
pageCount
)
// Render page buttons based on the current group
const renderPageButtons = () => {
const buttons = []
for (let i = currentGroupStart; i < currentGroupEnd; i++) {
buttons.push(
<Button
key={i}
size="icon-sm"
variant="ghost"
className={cn(btnBaseClasses, "text-muted-foreground", {
"bg-accent text-accent-foreground": pageIndex === i,
})}
onClick={() => {
if (pageIndex !== i) {
table.setPageIndex(i)
}
}}
>
{i + 1}
</Button>
)
}
return buttons
}
// Render a "previous" ellipsis button if there are previous pages to show
const renderEllipsisPrevButton = () => {
if (currentGroupStart > 0) {
return (
<Button
size="icon-sm"
className={btnBaseClasses}
variant="ghost"
onClick={() => table.setPageIndex(currentGroupStart - 1)}
>
{mergedProps.ellipsisText}
</Button>
)
}
return null
}
// Render a "next" ellipsis button if there are more pages to show after the current group
const renderEllipsisNextButton = () => {
if (currentGroupEnd < pageCount) {
return (
<Button
className={btnBaseClasses}
variant="ghost"
size="icon-sm"
onClick={() => table.setPageIndex(currentGroupEnd)}
>
{mergedProps.ellipsisText}
</Button>
)
}
return null
}
return (
<div
data-slot="data-grid-pagination"
className={cn(
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
mergedProps?.className
)}
>
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
{isLoading ? (
mergedProps?.sizesSkeleton
) : (
<>
<div className="text-muted-foreground text-sm">
{mergedProps.rowsPerPageLabel}
</div>
<Select
value={`${pageSize}`}
onValueChange={(value) => {
const newPageSize = Number(value)
table.setPageSize(newPageSize)
}}
>
<SelectTrigger className="w-14" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent side="top" className="min-w-18">
{mergedProps?.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
{isLoading ? (
mergedProps?.infoSkeleton
) : (
<>
<div className="text-muted-foreground text-sm order-2 text-nowrap sm:order-1">
{paginationInfo}
</div>
{pageCount > 1 && (
<div className="order-1 flex items-center space-x-1 sm:order-2">
<Button
size="icon-sm"
variant="ghost"
className={btnArrowClasses}
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">
{mergedProps.previousPageLabel}
</span>
<ChevronLeftIcon className="size-4" />
</Button>
{renderEllipsisPrevButton()}
{renderPageButtons()}
{renderEllipsisNextButton()}
<Button
size="icon-sm"
variant="ghost"
className={btnArrowClasses}
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">{mergedProps.nextPageLabel}</span>
<ChevronRightIcon className="size-4" />
</Button>
</div>
)}
</>
)}
</div>
</div>
)
}
export { DataGridPagination, type DataGridPaginationProps }
@@ -0,0 +1,421 @@
"use client"
import {
PointerEvent,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
const MIN_THUMB_SIZE = 24
const FALLBACK_SCROLLBAR_SIZE = 12
const INITIAL_METRICS = {
hasVerticalOverflow: false,
headerHeight: 0,
horizontalScrollbarSize: 0,
thumbHeight: 0,
thumbTop: 0,
trackHeight: 0,
} as const
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
type ScrollbarMetrics = {
hasVerticalOverflow: boolean
headerHeight: number
horizontalScrollbarSize: number
thumbHeight: number
thumbTop: number
trackHeight: number
}
type ObservedElements = {
header: HTMLElement | null
horizontalScrollbar: HTMLElement | null
table: HTMLElement | null
tableViewport: HTMLElement | null
}
type DataGridScrollAreaProps = Omit<
ScrollAreaPrimitive.Root.Props,
"children"
> & {
children: ReactNode
orientation?: DataGridScrollAreaOrientation
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) {
return (
next.hasVerticalOverflow === prev.hasVerticalOverflow &&
next.headerHeight === prev.headerHeight &&
next.horizontalScrollbarSize === prev.horizontalScrollbarSize &&
next.thumbHeight === prev.thumbHeight &&
next.thumbTop === prev.thumbTop &&
next.trackHeight === prev.trackHeight
)
}
function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) {
element.style.setProperty(
"--data-grid-scrollbar-header-height",
`${metrics.headerHeight}px`
)
element.style.setProperty(
"--data-grid-scrollbar-thumb-height",
`${metrics.thumbHeight}px`
)
element.style.setProperty(
"--data-grid-scrollbar-thumb-top",
`${metrics.thumbTop}px`
)
element.style.setProperty(
"--data-grid-scrollbar-track-height",
`${metrics.trackHeight}px`
)
}
function DataGridScrollArea({
children,
className,
orientation = "both",
...props
}: DataGridScrollAreaProps) {
const { props: dataGridProps } = useDataGrid()
const containerRef = useRef<HTMLDivElement>(null)
const viewportRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<{
pointerId: number
startScrollTop: number
startY: number
} | null>(null)
const metricsRef = useRef<ScrollbarMetrics>(INITIAL_METRICS)
const observedElementsRef = useRef<ObservedElements>({
header: null,
horizontalScrollbar: null,
table: null,
tableViewport: null,
})
const showHorizontal = orientation !== "vertical"
const showVertical = orientation !== "horizontal"
const usesCustomVerticalScrollbar =
showVertical && !!dataGridProps.tableLayout?.headerSticky
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
useState(false)
const clearDragState = useCallback(() => {
dragRef.current = null
document.body.style.userSelect = ""
document.body.style.webkitUserSelect = ""
}, [])
const resetMetrics = useCallback(() => {
const container = containerRef.current
if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
applyMetrics(container, INITIAL_METRICS)
metricsRef.current = INITIAL_METRICS
}
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
}, [])
const syncCustomVerticalScrollbar = useCallback(() => {
const container = containerRef.current
const viewport = viewportRef.current
if (!container || !viewport || !usesCustomVerticalScrollbar) {
resetMetrics()
return
}
const { header, horizontalScrollbar } = observedElementsRef.current
const headerHeight = header?.getBoundingClientRect().height ?? 0
const viewportHeight = viewport.clientHeight
const viewportWidth = viewport.clientWidth
const scrollHeight = viewport.scrollHeight
const scrollWidth = viewport.scrollWidth
const hasHorizontalOverflow =
showHorizontal && scrollWidth > viewportWidth + 0.5
const horizontalScrollbarSize = hasHorizontalOverflow
? horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE
: 0
const trackHeight = Math.max(
0,
viewportHeight - headerHeight - horizontalScrollbarSize
)
const maxScroll = Math.max(0, scrollHeight - viewportHeight)
let nextMetrics: ScrollbarMetrics
if (trackHeight === 0 || maxScroll === 0) {
nextMetrics = {
hasVerticalOverflow: false,
headerHeight,
horizontalScrollbarSize,
thumbHeight: trackHeight,
thumbTop: 0,
trackHeight,
}
} else {
const bodyContentHeight = Math.max(
trackHeight,
scrollHeight - headerHeight
)
const thumbHeight = clamp(
trackHeight * (trackHeight / bodyContentHeight),
MIN_THUMB_SIZE,
trackHeight
)
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
const thumbTop =
maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0
nextMetrics = {
hasVerticalOverflow: true,
headerHeight,
horizontalScrollbarSize,
thumbHeight,
thumbTop,
trackHeight,
}
}
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
applyMetrics(container, nextMetrics)
metricsRef.current = nextMetrics
}
setHasCustomVerticalOverflow((prev) =>
prev === nextMetrics.hasVerticalOverflow
? prev
: nextMetrics.hasVerticalOverflow
)
}, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar])
useEffect(() => {
const container = containerRef.current
const viewport = viewportRef.current
if (!container || !viewport) return
if (!usesCustomVerticalScrollbar) {
resetMetrics()
return
}
observedElementsRef.current = {
header: container.querySelector(
'[data-slot="data-grid-table"] thead'
) as HTMLElement | null,
horizontalScrollbar: container.querySelector(
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
) as HTMLElement | null,
table: container.querySelector(
'[data-slot="data-grid-table"]'
) as HTMLElement | null,
tableViewport: container.querySelector(
'[data-slot="data-grid-table-viewport"]'
) as HTMLElement | null,
}
let frame = 0
const scheduleSync = () => {
cancelAnimationFrame(frame)
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
}
scheduleSync()
viewport.addEventListener("scroll", scheduleSync, { passive: true })
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(scheduleSync)
observer?.observe(viewport)
observedElementsRef.current.header &&
observer?.observe(observedElementsRef.current.header)
observedElementsRef.current.table &&
observer?.observe(observedElementsRef.current.table)
observedElementsRef.current.tableViewport &&
observer?.observe(observedElementsRef.current.tableViewport)
return () => {
cancelAnimationFrame(frame)
observer?.disconnect()
viewport.removeEventListener("scroll", scheduleSync)
clearDragState()
}
}, [
clearDragState,
resetMetrics,
syncCustomVerticalScrollbar,
usesCustomVerticalScrollbar,
])
const scrollToThumbOffset = (nextThumbTop: number) => {
const viewport = viewportRef.current
const { thumbHeight, trackHeight } = metricsRef.current
if (!viewport) return
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
if (maxScroll === 0 || maxThumbTop === 0) {
viewport.scrollTop = 0
return
}
const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop
viewport.scrollTop = ratio * maxScroll
}
const handleThumbPointerDown = (event: PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current
if (!viewport) return
event.preventDefault()
event.stopPropagation()
event.currentTarget.setPointerCapture(event.pointerId)
dragRef.current = {
pointerId: event.pointerId,
startScrollTop: viewport.scrollTop,
startY: event.clientY,
}
document.body.style.userSelect = "none"
document.body.style.webkitUserSelect = "none"
}
const handleThumbPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current
const dragState = dragRef.current
const { thumbHeight, trackHeight } = metricsRef.current
if (!viewport || !dragState || dragState.pointerId !== event.pointerId) {
return
}
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
if (maxThumbTop === 0 || maxScroll === 0) return
const deltaY = event.clientY - dragState.startY
const nextScrollTop =
dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll
viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll)
}
const handleThumbPointerUp = (event: PointerEvent<HTMLDivElement>) => {
if (dragRef.current?.pointerId !== event.pointerId) return
clearDragState()
}
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
const { thumbHeight } = metricsRef.current
if (event.target !== event.currentTarget) return
event.preventDefault()
event.stopPropagation()
const rect = event.currentTarget.getBoundingClientRect()
const offsetY = event.clientY - rect.top - thumbHeight / 2
scrollToThumbOffset(offsetY)
}
return (
<div ref={containerRef} className="relative">
<ScrollAreaPrimitive.Root
data-slot="data-grid-scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
data-slot="scroll-area-viewport"
className="size-full"
>
<ScrollAreaPrimitive.Content data-slot="scroll-area-content">
{children}
</ScrollAreaPrimitive.Content>
</ScrollAreaPrimitive.Viewport>
{showHorizontal && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="horizontal"
orientation="horizontal"
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className="bg-border rounded-full relative flex-1"
/>
</ScrollAreaPrimitive.Scrollbar>
)}
{showVertical && !usesCustomVerticalScrollbar && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="vertical"
orientation="vertical"
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className="bg-border rounded-full relative flex-1"
/>
</ScrollAreaPrimitive.Scrollbar>
)}
</ScrollAreaPrimitive.Root>
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
>
<div
className="pointer-events-auto relative h-full w-2 touch-none p-px"
onPointerDown={handleTrackPointerDown}
>
<div
className={cn(
"bg-border absolute end-px w-2",
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
"rounded-full"
)}
onLostPointerCapture={clearDragState}
onPointerCancel={handleThumbPointerUp}
onPointerDown={handleThumbPointerDown}
onPointerMove={handleThumbPointerMove}
onPointerUp={handleThumbPointerUp}
/>
</div>
</div>
)}
</div>
)
}
export { DataGridScrollArea }
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
@@ -0,0 +1,309 @@
"use client"
import {
createContext,
CSSProperties,
ReactNode,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
UniqueIdentifier,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { Cell, flexRender, HeaderGroup, Row } from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { GripHorizontalIcon } from "lucide-react"
// Context to share sortable listeners from row to handle
type SortableContextValue = ReturnType<typeof useSortable>
const SortableRowContext = createContext<Pick<
SortableContextValue,
"attributes" | "listeners"
> | null>(null)
function DataGridTableDndRowHandle({ className }: { className?: string }) {
const context = useContext(SortableRowContext)
if (!context) {
// Fallback if context is not available (shouldn't happen in normal usage)
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
disabled
>
<GripHorizontalIcon
/>
</Button>
)
}
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
{...context.attributes}
{...context.listeners}
>
<GripHorizontalIcon
/>
</Button>
)
}
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
const {
transform,
transition,
setNodeRef,
isDragging,
attributes,
listeners,
} = useSortable({
id: row.id,
})
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: transition,
opacity: isDragging ? 0.8 : 1,
zIndex: isDragging ? 1 : 0,
position: "relative",
cursor: isDragging ? "grabbing" : undefined,
}
return (
<SortableRowContext.Provider value={{ attributes, listeners }}>
<DataGridTableBodyRow
row={row}
dndRef={setNodeRef}
dndStyle={style}
key={row.id}
>
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => {
return (
<DataGridTableBodyRowCell cell={cell} key={colIndex}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
})}
</DataGridTableBodyRow>
</SortableRowContext.Provider>
)
}
function DataGridTableDndRows<TData>({
handleDragEnd,
dataIds,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
dataIds: UniqueIdentifier[]
footerContent?: ReactNode
}) {
const { table, isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
const tableContainerRef = useRef<HTMLDivElement>(null)
const [isDraggingRow, setIsDraggingRow] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
useEffect(() => {
if (!isDraggingRow) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingRow])
const modifiers = useMemo(() => {
const restrictToTableContainer: Modifier = ({
transform,
draggingNodeRect,
}) => {
if (!tableContainerRef.current || !draggingNodeRect) {
return transform
}
const containerRect = tableContainerRef.current.getBoundingClientRect()
const { x, y } = transform
const minX = containerRect.left - draggingNodeRect.left
const maxX = containerRect.right - draggingNodeRect.right
const minY = containerRect.top - draggingNodeRect.top
const maxY = containerRect.bottom - draggingNodeRect.bottom
return {
...transform,
x: Math.max(minX, Math.min(maxX, x)),
y: Math.max(minY, Math.min(maxY, y)),
}
}
return [restrictToVerticalAxis, restrictToTableContainer]
}, [])
return (
<DndContext
id={useId()}
collisionDetection={closestCenter}
modifiers={modifiers}
onDragCancel={() => setIsDraggingRow(false)}
onDragEnd={(event) => {
setIsDraggingRow(false)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingRow(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={tableContainerRef}
className={
isDraggingRow
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
{headerGroup.headers.map((header, index) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={index}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
{props.loadingMode === "skeleton" &&
isLoading &&
pagination?.pageSize ? (
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
</DataGridTableBodyRowSkeleton>
))
) : table.getRowModel().rows.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row: Row<TData>) => {
return <DataGridTableDndRow row={row} key={row.id} />
})}
</SortableContext>
) : (
<DataGridTableEmpty />
)}
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDndRowHandle, DataGridTableDndRows }
@@ -0,0 +1,314 @@
"use client"
import {
CSSProperties,
Fragment,
ReactNode,
useEffect,
useId,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
Modifier,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core"
import {
horizontalListSortingStrategy,
SortableContext,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
Cell,
flexRender,
Header,
HeaderGroup,
Row,
} from "@tanstack/react-table"
import { Button } from "@/components/ui/button"
import { GripVerticalIcon } from "lucide-react"
function DataGridTableDndHeader<TData>({
header,
}: {
header: Header<TData, unknown>
}) {
const { props } = useDataGrid()
const { column } = header
// Check if column ordering is enabled for this column
const canOrder =
(column.columnDef as { enableColumnOrdering?: boolean })
.enableColumnOrdering !== false
const {
attributes,
isDragging,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({
id: header.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
whiteSpace: "nowrap",
width: props.tableLayout?.columnsResizable
? `calc(var(--header-${header.id}-size) * 1px)`
: header.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableHeadRowCell
header={header}
dndStyle={style}
dndRef={setNodeRef}
>
<div className="flex items-center justify-start gap-0.5">
{canOrder && (
<Button
size="icon-sm"
variant="ghost"
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
{...attributes}
{...listeners}
aria-label="Drag to reorder"
>
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button>
)}
<span className="grow truncate">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</span>
{props.tableLayout?.columnsResizable && column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</div>
</DataGridTableHeadRowCell>
)
}
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
const { props } = useDataGrid()
const { isDragging, setNodeRef, transform, transition } = useSortable({
id: cell.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
width: props.tableLayout?.columnsResizable
? `calc(var(--col-${cell.column.id}-size) * 1px)`
: cell.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
}
function DataGridTableDnd<TData>({
handleDragEnd,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
footerContent?: ReactNode
}) {
const { table, isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
const containerRef = useRef<HTMLDivElement>(null)
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
useEffect(() => {
if (!isDraggingColumn) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingColumn])
// Custom modifier to restrict dragging within table bounds with edge offset
const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => {
if (!draggingNodeRect || !containerRef.current) {
return { ...transform, y: 0 }
}
const containerRect = containerRef.current.getBoundingClientRect()
const edgeOffset = 0
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
const maxX =
containerRect.right -
draggingNodeRect.left -
draggingNodeRect.width +
edgeOffset
return {
...transform,
x: Math.min(Math.max(transform.x, minX), maxX),
y: 0, // Lock vertical movement
}
}
return (
<DndContext
collisionDetection={closestCenter}
id={useId()}
modifiers={[restrictToTableBounds]}
onDragCancel={() => setIsDraggingColumn(false)}
onDragEnd={(event) => {
setIsDraggingColumn(false)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingColumn(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={containerRef}
className={
isDraggingColumn
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
{headerGroup.headers.map((header) => (
<DataGridTableDndHeader
header={header}
key={header.id}
/>
))}
</SortableContext>
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
{props.loadingMode === "skeleton" &&
isLoading &&
pagination?.pageSize ? (
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
</DataGridTableBodyRowSkeleton>
))
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row: Row<TData>) => {
return (
<Fragment key={row.id}>
<DataGridTableBodyRow row={row}>
{row
.getVisibleCells()
.map((cell: Cell<TData, unknown>) => {
return (
<SortableContext
key={cell.id}
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
<DataGridTableDndCell cell={cell} />
</SortableContext>
)
})}
</DataGridTableBodyRow>
{row.getIsExpanded() && (
<DataGridTableBodyRowExpandded row={row} />
)}
</Fragment>
)
})
) : (
<DataGridTableEmpty />
)}
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDnd }
@@ -0,0 +1,492 @@
"use client"
import {
memo,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRenderedRow,
DataGridTableRowSpacer,
DataGridTableViewport,
getDataGridTableRowSections,
} from "@/components/reui/data-grid/data-grid-table"
import { flexRender, HeaderGroup, Row, Table } from "@tanstack/react-table"
import {
useVirtualizer,
VirtualItem,
Virtualizer,
VirtualizerOptions,
} from "@tanstack/react-virtual"
import { cn } from "@/lib/utils"
import { Spinner } from "@/components/ui/spinner"
type DataGridTableVirtualScrollElements = {
containerElement: HTMLDivElement | null
scrollElement: HTMLElement | null
}
type DataGridTableVirtualizerInstance = Virtualizer<
HTMLElement,
HTMLTableRowElement
>
type DataGridTableVirtualizerOptions<TData> = Omit<
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
> & {
estimateSize?: (index: number, row: Row<TData>) => number
getItemKey?: (index: number, row: Row<TData>) => string | number
getScrollElement?: (
elements: DataGridTableVirtualScrollElements
) => HTMLElement | null
}
interface DataGridTableVirtualProps<TData> {
height?: number | string
estimateSize?: number
overscan?: number
footerContent?: ReactNode
renderHeader?: boolean
onFetchMore?: () => void
isFetchingMore?: boolean
hasMore?: boolean
fetchMoreOffset?: number
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
}
interface VirtualBodyProps<TData> {
table: Table<TData>
columnCount: number
topRows: Row<TData>[]
centerRows: Row<TData>[]
bottomRows: Row<TData>[]
virtualItems: VirtualItem[]
totalSize: number
isVirtualizationEnabled: boolean
isInfiniteMode: boolean
isFetchingMore: boolean
hasMore?: boolean
loadingMoreMessage: ReactNode
allRowsLoadedMessage: ReactNode
measureRowRef?: (element: HTMLTableRowElement | null) => void
}
function DataGridTableVirtualSpacer({
columnCount,
height,
}: {
columnCount: number
height: number
}) {
if (height <= 0) return null
return (
<tr aria-hidden="true">
<td colSpan={columnCount} style={{ height, padding: 0 }} />
</tr>
)
}
function DataGridTableVirtualStatusRow({
children,
className,
columnCount,
}: {
children: ReactNode
className?: string
columnCount: number
}) {
return (
<tr>
<td
colSpan={columnCount}
className={cn(
"text-muted-foreground py-4 text-center text-sm",
className
)}
>
{children}
</td>
</tr>
)
}
function DataGridTableVirtualBody<TData>({
table,
columnCount,
topRows,
centerRows,
bottomRows,
virtualItems,
totalSize,
isVirtualizationEnabled,
isInfiniteMode,
isFetchingMore,
hasMore,
loadingMoreMessage,
allRowsLoadedMessage,
measureRowRef,
}: VirtualBodyProps<TData>) {
const totalRows = topRows.length + centerRows.length + bottomRows.length
if (!totalRows) return <DataGridTableEmpty />
const hasCenterRows = centerRows.length > 0
const showFetchingRow = isInfiniteMode && isFetchingMore
const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0
const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow
const leadingSpacerHeight =
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
? (virtualItems[0]?.start ?? 0)
: 0
const trailingSpacerHeight =
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
? Math.max(
0,
totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)
)
: 0
const renderedRows: ReactNode[] = []
topRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
pinnedBoundary={
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
}
/>
)
})
if (isVirtualizationEnabled) {
if (leadingSpacerHeight > 0) {
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-start"
columnCount={columnCount}
height={leadingSpacerHeight}
/>
)
}
virtualItems.forEach((virtualRow) => {
const row = centerRows[virtualRow.index]
if (!row) return
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
rowRef={measureRowRef}
/>
)
})
if (trailingSpacerHeight > 0) {
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-end"
columnCount={columnCount}
height={trailingSpacerHeight}
/>
)
}
} else {
centerRows.forEach((row) => {
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
})
}
if (showFetchingRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow
key="virtual-status-loading"
columnCount={columnCount}
>
<div className="flex items-center justify-center gap-2">
<Spinner className="size-4 opacity-60" />
{loadingMoreMessage}
</div>
</DataGridTableVirtualStatusRow>
)
}
if (showCompleteRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow
key="virtual-status-complete"
columnCount={columnCount}
className="py-3 text-xs"
>
{allRowsLoadedMessage}
</DataGridTableVirtualStatusRow>
)
}
bottomRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
pinnedBoundary={
index === 0 && (topRows.length > 0 || hasMiddleSection)
? "bottom"
: undefined
}
/>
)
})
return <>{renderedRows}</>
}
/**
* Memoized virtual body: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
*/
const MemoizedVirtualBody = memo(
DataGridTableVirtualBody,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
) as typeof DataGridTableVirtualBody
function DataGridTableVirtual<TData>({
height,
estimateSize = 48,
overscan = 10,
footerContent,
renderHeader = true,
onFetchMore,
isFetchingMore = false,
hasMore,
fetchMoreOffset = 0,
virtualizerOptions,
}: DataGridTableVirtualProps<TData>) {
const { table, props } = useDataGrid()
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
table,
props.tableLayout?.rowsPinnable
)
const columnCount =
table.getVisibleFlatColumns().length +
(props.tableLayout?.columnsResizable ? 1 : 0)
const isInfiniteMode = typeof onFetchMore === "function"
const [viewportElements, setViewportElements] =
useState<DataGridTableVirtualScrollElements>({
containerElement: null,
scrollElement: null,
})
const {
estimateSize: customEstimateSize,
getItemKey: customGetItemKey,
getScrollElement: customGetScrollElement,
measureElement: customMeasureElement,
overscan: customOverscan,
...virtualizerOptionsRest
} = virtualizerOptions ?? {}
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
const loadingMoreMessage =
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
const allRowsLoadedMessage =
props.allRowsLoadedMessage || "All records loaded"
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
setViewportElements({
containerElement: node,
scrollElement:
(node?.closest(
'[data-slot="scroll-area-viewport"]'
) as HTMLElement | null) ?? node,
})
}, [])
const usesExternalScrollArea =
viewportElements.scrollElement !== null &&
viewportElements.scrollElement !== viewportElements.containerElement
const resolveScrollElement = useCallback(() => {
if (customGetScrollElement) {
return customGetScrollElement(viewportElements)
}
return viewportElements.scrollElement
}, [customGetScrollElement, viewportElements])
const resolveItemKey = useCallback(
(index: number) => {
const row = centerRows[index]
if (!row) return index
return customGetItemKey?.(index, row) ?? row.id ?? index
},
[centerRows, customGetItemKey]
)
const resolveEstimateSize = useCallback(
(index: number) => {
const row = centerRows[index]
return row
? (customEstimateSize?.(index, row) ?? estimateSize)
: estimateSize
},
[centerRows, customEstimateSize, estimateSize]
)
const virtualizer = useVirtualizer({
count: centerRows.length,
getScrollElement: resolveScrollElement,
getItemKey: resolveItemKey,
estimateSize: resolveEstimateSize,
overscan: customOverscan ?? overscan,
measureElement: customMeasureElement,
...virtualizerOptionsRest,
}) as DataGridTableVirtualizerInstance
const virtualItems = isVirtualizationEnabled
? virtualizer.getVirtualItems()
: []
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
const measureRowRef =
isVirtualizationEnabled && customMeasureElement
? virtualizer.measureElement
: undefined
const resolvedFetchMoreOffset = useMemo(
() => Math.max(0, fetchMoreOffset),
[fetchMoreOffset]
)
useEffect(() => {
if (
!isVirtualizationEnabled ||
!isInfiniteMode ||
hasMore === false ||
isFetchingMore
) {
return
}
const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
onFetchMore?.()
}
}, [
centerRows.length,
hasMore,
isFetchingMore,
isInfiniteMode,
isVirtualizationEnabled,
onFetchMore,
resolvedFetchMoreOffset,
virtualItems,
])
return (
<DataGridTableViewport
viewportRef={handleViewportRef}
className={!usesExternalScrollArea ? "block" : undefined}
style={
usesExternalScrollArea
? undefined
: { height, overflow: "auto", position: "relative" }
}
>
<DataGridTableBase>
{renderHeader && (
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
{headerGroup.headers.map((header, hIndex) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={hIndex}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
</DataGridTableHeadRow>
))}
</DataGridTableHead>
)}
{renderHeader &&
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedVirtualBody
table={table}
columnCount={columnCount}
topRows={topRows}
centerRows={centerRows}
bottomRows={bottomRows}
virtualItems={virtualItems}
totalSize={totalSize}
isVirtualizationEnabled={isVirtualizationEnabled}
isInfiniteMode={isInfiniteMode}
isFetchingMore={isFetchingMore}
hasMore={hasMore}
loadingMoreMessage={loadingMoreMessage}
allRowsLoadedMessage={allRowsLoadedMessage}
measureRowRef={measureRowRef}
/>
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
)
}
export { DataGridTableVirtual }
export type {
DataGridTableVirtualProps,
DataGridTableVirtualScrollElements,
DataGridTableVirtualizerOptions,
}
File diff suppressed because it is too large Load Diff
+270
View File
@@ -0,0 +1,270 @@
"use client"
import { createContext, ReactNode, useContext, useMemo } from "react"
import {
Column,
ColumnFiltersState,
RowData,
SortingState,
Table,
} from "@tanstack/react-table"
import { cn } from "@/lib/utils"
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
headerTitle?: string
headerClassName?: string
cellClassName?: string
skeleton?: ReactNode
expandedContent?: (row: TData) => ReactNode
}
}
/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */
export function getColumnHeaderLabel<TData, TValue>(
column: Column<TData, TValue>
): string {
const meta = column.columnDef.meta as { headerTitle?: string } | undefined
if (typeof meta?.headerTitle === "string") return meta.headerTitle
const defHeader = column.columnDef.header
if (typeof defHeader === "string") return defHeader
return String(column.id)
}
export type DataGridApiFetchParams = {
pageIndex: number
pageSize: number
sorting?: SortingState
filters?: ColumnFiltersState
searchQuery?: string
}
export type DataGridApiResponse<T> = {
data: T[]
empty: boolean
pagination: {
total: number
page: number
}
}
export interface DataGridContextProps<TData extends object> {
props: DataGridProps<TData>
table: Table<TData>
recordCount: number
isLoading: boolean
}
export type DataGridRequestParams = {
pageIndex: number
pageSize: number
sorting?: SortingState
columnFilters?: ColumnFiltersState
}
export interface DataGridProps<TData extends object> {
className?: string
table?: Table<TData>
recordCount: number
children?: ReactNode
onRowClick?: (row: TData) => void
isLoading?: boolean
loadingMode?: "skeleton" | "spinner"
loadingMessage?: ReactNode | string
fetchingMoreMessage?: ReactNode | string
allRowsLoadedMessage?: ReactNode | string
emptyMessage?: ReactNode | string
tableLayout?: {
dense?: boolean
cellBorder?: boolean
rowBorder?: boolean
rowRounded?: boolean
stripped?: boolean
headerBackground?: boolean
headerBorder?: boolean
headerSticky?: boolean
width?: "auto" | "fixed"
columnsVisibility?: boolean
columnsResizable?: boolean
columnsResizeMode?: "onChange" | "onEnd"
columnsPinnable?: boolean
columnsMovable?: boolean
columnsDraggable?: boolean
rowsDraggable?: boolean
rowsPinnable?: boolean
}
tableClassNames?: {
base?: string
header?: string
headerRow?: string
headerSticky?: string
body?: string
bodyRow?: string
footer?: string
edgeCell?: string
}
}
const DataGridContext = createContext<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
DataGridContextProps<any> | undefined
>(undefined)
function useDataGrid() {
const context = useContext(DataGridContext)
if (!context) {
throw new Error("useDataGrid must be used within a DataGridProvider")
}
return context
}
function DataGridProvider<TData extends object>({
children,
table,
...props
}: DataGridProps<TData> & { table: Table<TData> }) {
const tableState = table.getState()
const resolvedColumnsResizeMode =
props.tableLayout?.columnsResizeMode ?? "onEnd"
// Keep resize mode aligned with the DataGrid contract every render so
// consumer-level useReactTable options cannot flip it back between drags.
if (props.tableLayout?.columnsResizable) {
table.options.columnResizeMode = resolvedColumnsResizeMode
}
// Memoize context value so consumers don't re-render during column resize.
// Column sizing state is intentionally excluded from deps -- CSS variables
// on the <table> element handle width updates without React re-renders.
const value = useMemo(
() => ({
props,
table,
recordCount: props.recordCount,
isLoading: props.isLoading || false,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
table,
props.recordCount,
props.isLoading,
props.loadingMode,
props.loadingMessage,
props.fetchingMoreMessage,
props.allRowsLoadedMessage,
props.emptyMessage,
props.onRowClick,
props.className,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(props.tableLayout),
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(props.tableClassNames),
tableState.sorting,
tableState.pagination,
tableState.columnFilters,
tableState.rowSelection,
tableState.expanded,
tableState.columnVisibility,
tableState.columnOrder,
tableState.columnPinning,
tableState.globalFilter,
]
)
return (
<DataGridContext.Provider value={value}>
{children}
</DataGridContext.Provider>
)
}
function DataGrid<TData extends object>({
children,
table,
...props
}: DataGridProps<TData>) {
const defaultProps: Partial<DataGridProps<TData>> = {
loadingMode: "skeleton",
tableLayout: {
dense: false,
cellBorder: false,
rowBorder: true,
rowRounded: false,
stripped: false,
headerSticky: false,
headerBackground: true,
headerBorder: true,
width: "fixed",
columnsVisibility: false,
columnsResizable: false,
columnsResizeMode: "onEnd",
columnsPinnable: false,
columnsMovable: false,
columnsDraggable: false,
rowsDraggable: false,
rowsPinnable: false,
},
tableClassNames: {
base: "",
header: "",
headerRow: "",
headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs",
body: "",
bodyRow: "",
footer: "",
edgeCell: "",
},
}
const mergedProps: DataGridProps<TData> = {
...defaultProps,
...props,
tableLayout: {
...defaultProps.tableLayout,
...(props.tableLayout || {}),
},
tableClassNames: {
...defaultProps.tableClassNames,
...(props.tableClassNames || {}),
},
}
// Ensure table is provided
if (!table) {
throw new Error('DataGrid requires a "table" prop')
}
return (
<DataGridProvider table={table} {...mergedProps}>
{children}
</DataGridProvider>
)
}
function DataGridContainer({
children,
className,
border = true,
}: {
children: ReactNode
className?: string
border?: boolean
}) {
return (
<div
data-slot="data-grid"
className={cn(
"w-full overflow-hidden",
border &&
"border-border rounded-lg border",
className
)}
>
{children}
</div>
)
}
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
+36
View File
@@ -0,0 +1,36 @@
export {
DataGrid,
DataGridContainer,
DataGridProvider,
useDataGrid,
getColumnHeaderLabel,
type DataGridProps,
type DataGridApiFetchParams,
type DataGridApiResponse,
} from "./data-grid"
export { DataGridColumnFilter } from "./data-grid-column-filter"
export { DataGridColumnHeader } from "./data-grid-column-header"
export { DataGridColumnVisibility } from "./data-grid-column-visibility"
export { DataGridPagination } from "./data-grid-pagination"
export { DataGridScrollArea } from "./data-grid-scroll-area"
export { DataGridTableDndRowHandle, DataGridTableDndRows } from "./data-grid-table-dnd-rows"
export { DataGridTableDnd } from "./data-grid-table-dnd"
export { DataGridTableVirtual } from "./data-grid-table-virtual"
export {
DataGridTable,
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableFootRow,
DataGridTableFootRowCell,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableRowSelect,
DataGridTableRowSelectAll,
getDataGridTableRowSections,
} from "./data-grid-table"
File diff suppressed because it is too large Load Diff
+170
View File
@@ -0,0 +1,170 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
/**
* CSS variable architecture for FramePanel theming:
*
* The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
* FramePanel consumes them directly via bg-(--frame-panel-bg) and
* border-(--frame-panel-border-color). This means:
*
* - variant="inverse" overrides those vars on Frame all panels pick it up
* - <FramePanel className="bg-blue-50"> adds a direct utility on the element
* which wins over bg-(--frame-panel-bg) by Tailwind source order no
* :not() or !important needed
*/
const frameVariants = cva(
[
"relative flex flex-col bg-muted/50 gap-0.75 p-0.75 rounded-(--frame-radius)",
"[--frame-radius:var(--radius-xl)]",
// Default panel token values — overridden per-variant below
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
],
{
variants: {
variant: {
default: "border border-[var(--frame-border-color)] bg-clip-padding",
inverse:
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
ghost: "",
},
spacing: {
xs: "[--frame-panel-p:--spacing(2)] [--frame-panel-header-px:--spacing(2)] [--frame-panel-header-py:--spacing(1)] [--frame-panel-footer-px:--spacing(2)] [--frame-panel-footer-py:--spacing(1)]",
sm: "[--frame-panel-p:--spacing(3)] [--frame-panel-header-px:--spacing(3)] [--frame-panel-header-py:--spacing(2)] [--frame-panel-footer-px:--spacing(3)] [--frame-panel-footer-py:--spacing(2)]",
default:
"[--frame-panel-p:--spacing(4)] [--frame-panel-header-px:--spacing(4)] [--frame-panel-header-py:--spacing(3)] [--frame-panel-footer-px:--spacing(4)] [--frame-panel-footer-py:--spacing(3)]",
lg: "[--frame-panel-p:--spacing(5)] [--frame-panel-header-px:--spacing(5)] [--frame-panel-header-py:--spacing(4)] [--frame-panel-footer-px:--spacing(5)] [--frame-panel-footer-py:--spacing(4)]",
},
stacked: {
true: [
"gap-0 *:has-[+[data-slot=frame-panel]]:rounded-b-none",
"*:has-[+[data-slot=frame-panel]]:before:hidden",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:rounded-t-none",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:border-t-0",
// No FrameHeader present: first panel sits flush against the outer frame border
"[&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:border-t-0",
],
false: [
"data-[spacing=sm]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-0.5",
"data-[spacing=default]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-1",
"data-[spacing=lg]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-2",
],
},
dense: {
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars
true: "p-0 gap-0 border-[var(--frame-border-color)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px",
false: "",
},
},
defaultVariants: {
variant: "default",
spacing: "default",
stacked: false,
dense: false,
},
}
)
function Frame({
className,
variant,
spacing,
stacked,
dense,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof frameVariants>) {
return (
<div
className={cn(
frameVariants({ variant, spacing, stacked, dense }),
className
)}
data-slot="frame"
data-spacing={spacing}
{...props}
/>
)
}
function FramePanel({
className,
fit,
...props
}: React.ComponentProps<"div"> & { fit?: boolean }) {
return (
<div
className={cn(
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
// via className overrides these by Tailwind source order — no ! needed.
"relative grow overflow-hidden rounded-(--frame-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-radius)-1px)] before:shadow-black/5",
"dark:bg-clip-border dark:before:shadow-white/5",
"p-(--frame-panel-p)",
className
)}
data-slot="frame-panel"
{...props}
/>
)
}
function FrameHeader({ className, ...props }: React.ComponentProps<"header">) {
return (
<header
className={cn(
"flex flex-col px-(--frame-panel-header-px) py-(--frame-panel-header-py)",
className
)}
data-slot="frame-panel-header"
{...props}
/>
)
}
function FrameTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("text-sm font-semibold", className)}
data-slot="frame-panel-title"
{...props}
/>
)
}
function FrameDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn("text-muted-foreground text-sm", className)}
data-slot="frame-panel-description"
{...props}
/>
)
}
function FrameFooter({ className, ...props }: React.ComponentProps<"footer">) {
return (
<footer
className={cn(
"flex flex-col gap-1 px-(--frame-panel-footer-px) py-(--frame-panel-footer-py)",
className
)}
data-slot="frame-panel-footer"
{...props}
/>
)
}
export {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
FrameFooter,
frameVariants,
}
+260
View File
@@ -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,
}
+417
View File
@@ -0,0 +1,417 @@
"use client"
import * as React from "react"
import {
Children,
cloneElement,
createContext,
CSSProperties,
isValidElement,
ReactElement,
ReactNode,
useCallback,
useContext,
useLayoutEffect,
useMemo,
useState,
} from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import {
defaultDropAnimationSideEffects,
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
DropAnimation,
KeyboardSensor,
MeasuringStrategy,
Modifiers,
MouseSensor,
TouchSensor,
UniqueIdentifier,
useSensor,
useSensors,
type DraggableSyntheticListeners,
} from "@dnd-kit/core"
import {
arrayMove,
defaultAnimateLayoutChanges,
rectSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
type AnimateLayoutChanges,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"
// Sortable Item Context
const SortableItemContext = createContext<{
listeners: DraggableSyntheticListeners | undefined
isDragging?: boolean
disabled?: boolean
}>({
listeners: undefined,
isDragging: false,
disabled: false,
})
const IsOverlayContext = createContext(false)
const SortableInternalContext = createContext<{
activeId: UniqueIdentifier | null
modifiers?: Modifiers
}>({
activeId: null,
modifiers: undefined,
})
const animateLayoutChanges: AnimateLayoutChanges = (args) =>
defaultAnimateLayoutChanges({ ...args, wasDragging: true })
const dropAnimationConfig: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: "0.4",
},
},
}),
}
// Multipurpose Sortable Component
export interface SortableRootProps<T> extends Omit<
useRender.ComponentProps<"div">,
"onDragStart" | "onDragEnd" | "children"
> {
value: T[]
onValueChange: (value: T[]) => void
getItemValue: (item: T) => string
children: ReactNode
onMove?: (event: {
event: DragEndEvent
activeIndex: number
overIndex: number
}) => void
strategy?: "horizontal" | "vertical" | "grid"
onDragStart?: (event: DragStartEvent) => void
onDragEnd?: (event: DragEndEvent) => void
modifiers?: Modifiers
}
function Sortable<T>({
value,
onValueChange,
getItemValue,
className,
render,
onMove,
strategy = "vertical",
onDragStart,
onDragEnd,
modifiers,
children,
...props
}: SortableRootProps<T>) {
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
const [mounted, setMounted] = useState(false)
useLayoutEffect(() => setMounted(true), [])
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: {
distance: 10,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 5,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const handleDragStart = useCallback(
(event: DragStartEvent) => {
setActiveId(event.active.id)
onDragStart?.(event)
},
[onDragStart]
)
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event
setActiveId(null)
onDragEnd?.(event)
if (!over) return
// Handle item reordering
const activeIndex = value.findIndex(
(item: T) => getItemValue(item) === active.id
)
const overIndex = value.findIndex(
(item: T) => getItemValue(item) === over.id
)
if (activeIndex !== overIndex) {
if (onMove) {
onMove({ event, activeIndex, overIndex })
} else {
const newValue = arrayMove(value, activeIndex, overIndex)
onValueChange(newValue)
}
}
},
[value, getItemValue, onValueChange, onMove, onDragEnd]
)
const handleDragCancel = useCallback(() => {
setActiveId(null)
}, [])
const getStrategy = () => {
switch (strategy) {
case "horizontal":
return rectSortingStrategy
case "grid":
return rectSortingStrategy
case "vertical":
default:
return verticalListSortingStrategy
}
}
const itemIds = useMemo(() => value.map(getItemValue), [value, getItemValue])
const contextValue = useMemo(
() => ({ activeId, modifiers }),
[activeId, modifiers]
)
const defaultProps = {
"data-slot": "sortable",
"data-dragging": activeId !== null,
className: cn(activeId !== null && "cursor-grabbing!", className),
children,
}
// Find the active child for the overlay
const overlayContent = useMemo(() => {
if (!activeId) return null
let result: ReactNode = null
Children.forEach(children, (child) => {
if (isValidElement(child) && (child.props as any).value === activeId) {
result = cloneElement(child as ReactElement<any>, {
...(child.props as any),
className: cn((child.props as any).className, "z-50"),
})
}
})
return result
}, [activeId, children])
return (
<SortableInternalContext.Provider value={contextValue}>
<DndContext
sensors={sensors}
modifiers={modifiers}
measuring={{
droppable: {
strategy: MeasuringStrategy.Always,
},
}}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext items={itemIds} strategy={getStrategy()}>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</SortableContext>
{mounted &&
createPortal(
<DragOverlay
dropAnimation={dropAnimationConfig}
modifiers={modifiers}
className={cn("z-50", activeId && "cursor-grabbing")}
>
<IsOverlayContext.Provider value={true}>
{overlayContent}
</IsOverlayContext.Provider>
</DragOverlay>,
document.body
)}
</DndContext>
</SortableInternalContext.Provider>
)
}
export interface SortableItemProps extends useRender.ComponentProps<"div"> {
value: string
disabled?: boolean
}
function SortableItem({
value,
className,
render,
disabled,
...props
}: SortableItemProps) {
const isOverlay = useContext(IsOverlayContext)
const {
setNodeRef,
transform,
transition,
attributes,
listeners,
isDragging: isSortableDragging,
} = useSortable({
id: value,
disabled: disabled || isOverlay,
animateLayoutChanges,
})
if (isOverlay) {
const defaultProps = {
"data-slot": "sortable-item",
"data-value": value,
"data-dragging": true,
className: cn(className),
children: props.children,
}
return (
<SortableItemContext.Provider
value={{ listeners: undefined, isDragging: true, disabled: false }}
>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</SortableItemContext.Provider>
)
}
const style = {
transition,
transform: CSS.Transform.toString(transform),
} as CSSProperties
const defaultProps = {
"data-slot": "sortable-item",
"data-value": value,
"data-dragging": isSortableDragging,
"data-disabled": disabled,
ref: setNodeRef,
style,
...attributes,
className: cn(
isSortableDragging && "opacity-50 z-50",
disabled && "opacity-50",
className
),
children: props.children,
}
return (
<SortableItemContext.Provider
value={{ listeners, isDragging: isSortableDragging, disabled }}
>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</SortableItemContext.Provider>
)
}
export interface SortableItemHandleProps extends useRender.ComponentProps<"div"> {
cursor?: boolean
}
function SortableItemHandle({
className,
render,
cursor = true,
...props
}: SortableItemHandleProps) {
const { listeners, isDragging, disabled } = useContext(SortableItemContext)
const defaultProps = {
"data-slot": "sortable-item-handle",
"data-dragging": isDragging,
"data-disabled": disabled,
...listeners,
className: cn(
cursor && (isDragging ? "cursor-grabbing!" : "cursor-grab!"),
className
),
children: props.children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
export interface SortableOverlayProps extends Omit<
React.ComponentProps<typeof DragOverlay>,
"children"
> {
children?: ReactNode | ((params: { value: UniqueIdentifier }) => ReactNode)
}
function SortableOverlay({
children,
className,
...props
}: SortableOverlayProps) {
const { activeId, modifiers } = useContext(SortableInternalContext)
const [mounted, setMounted] = useState(false)
useLayoutEffect(() => setMounted(true), [])
const content =
activeId && children
? typeof children === "function"
? children({ value: activeId })
: children
: null
if (!mounted) return null
return createPortal(
<DragOverlay
dropAnimation={dropAnimationConfig}
modifiers={modifiers}
className={cn("z-50", activeId && "cursor-grabbing", className)}
{...props}
>
<IsOverlayContext.Provider value={true}>
{content}
</IsOverlayContext.Provider>
</DragOverlay>,
document.body
)
}
export { Sortable, SortableItem, SortableItemHandle, SortableOverlay }
+489
View File
@@ -0,0 +1,489 @@
"use client"
import {
Children,
createContext,
HTMLAttributes,
isValidElement,
ReactElement,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { cn } from "@/lib/utils"
// Types
type StepperOrientation = "horizontal" | "vertical"
type StepState = "active" | "completed" | "inactive" | "loading"
type StepIndicators = {
active?: React.ReactNode
completed?: React.ReactNode
inactive?: React.ReactNode
loading?: React.ReactNode
}
interface StepperContextValue {
activeStep: number
setActiveStep: (step: number) => void
stepsCount: number
orientation: StepperOrientation
registerTrigger: (node: HTMLButtonElement | null) => void
triggerNodes: HTMLButtonElement[]
focusNext: (currentIdx: number) => void
focusPrev: (currentIdx: number) => void
focusFirst: () => void
focusLast: () => void
indicators: StepIndicators
}
interface StepItemContextValue {
step: number
state: StepState
isDisabled: boolean
isLoading: boolean
}
const StepperContext = createContext<StepperContextValue | undefined>(undefined)
const StepItemContext = createContext<StepItemContextValue | undefined>(
undefined
)
function useStepper() {
const ctx = useContext(StepperContext)
if (!ctx) throw new Error("useStepper must be used within a Stepper")
return ctx
}
function useStepItem() {
const ctx = useContext(StepItemContext)
if (!ctx) throw new Error("useStepItem must be used within a StepperItem")
return ctx
}
interface StepperProps extends HTMLAttributes<HTMLDivElement> {
defaultValue?: number
value?: number
onValueChange?: (value: number) => void
orientation?: StepperOrientation
indicators?: StepIndicators
}
function Stepper({
defaultValue = 1,
value,
onValueChange,
orientation = "horizontal",
className,
children,
indicators = {},
...props
}: StepperProps) {
const [activeStep, setActiveStep] = useState(defaultValue)
const [triggerNodes, setTriggerNodes] = useState<HTMLButtonElement[]>([])
// Register/unregister triggers
const registerTrigger = useCallback((node: HTMLButtonElement | null) => {
setTriggerNodes((prev) => {
if (node && !prev.includes(node)) {
return [...prev, node]
} else if (!node && prev.includes(node!)) {
return prev.filter((n) => n !== node)
} else {
return prev
}
})
}, [])
const handleSetActiveStep = useCallback(
(step: number) => {
if (value === undefined) {
setActiveStep(step)
}
onValueChange?.(step)
},
[value, onValueChange]
)
const currentStep = value ?? activeStep
// Keyboard navigation logic
const focusTrigger = (idx: number) => {
if (triggerNodes[idx]) triggerNodes[idx].focus()
}
const focusNext = (currentIdx: number) =>
focusTrigger((currentIdx + 1) % triggerNodes.length)
const focusPrev = (currentIdx: number) =>
focusTrigger((currentIdx - 1 + triggerNodes.length) % triggerNodes.length)
const focusFirst = () => focusTrigger(0)
const focusLast = () => focusTrigger(triggerNodes.length - 1)
// Context value
const contextValue = useMemo<StepperContextValue>(
() => ({
activeStep: currentStep,
setActiveStep: handleSetActiveStep,
stepsCount: Children.toArray(children).filter(
(child): child is ReactElement =>
isValidElement(child) &&
(child.type as { displayName?: string }).displayName === "StepperItem"
).length,
orientation,
registerTrigger,
focusNext,
focusPrev,
focusFirst,
focusLast,
triggerNodes,
indicators,
}),
[
currentStep,
handleSetActiveStep,
children,
orientation,
registerTrigger,
triggerNodes,
]
)
return (
<StepperContext.Provider value={contextValue}>
<div
role="tablist"
aria-orientation={orientation}
data-slot="stepper"
className={cn("w-full", className)}
data-orientation={orientation}
{...props}
>
{children}
</div>
</StepperContext.Provider>
)
}
interface StepperItemProps extends React.HTMLAttributes<HTMLDivElement> {
step: number
completed?: boolean
disabled?: boolean
loading?: boolean
}
function StepperItem({
step,
completed = false,
disabled = false,
loading = false,
className,
children,
...props
}: StepperItemProps) {
const { activeStep } = useStepper()
const state: StepState =
completed || step < activeStep
? "completed"
: activeStep === step
? "active"
: "inactive"
const isLoading = loading && step === activeStep
return (
<StepItemContext.Provider
value={{ step, state, isDisabled: disabled, isLoading }}
>
<div
data-slot="stepper-item"
className={cn(
"group/step flex items-center justify-center not-last:flex-1 group-data-[orientation=horizontal]/stepper-nav:flex-row group-data-[orientation=vertical]/stepper-nav:flex-col",
className
)}
data-state={state}
{...(isLoading ? { "data-loading": true } : {})}
{...props}
>
{children}
</div>
</StepItemContext.Provider>
)
}
interface StepperTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean
}
function StepperTrigger({
asChild = false,
className,
children,
tabIndex,
...props
}: StepperTriggerProps) {
const { state, isLoading } = useStepItem()
const stepperCtx = useStepper()
const {
setActiveStep,
activeStep,
registerTrigger,
triggerNodes,
focusNext,
focusPrev,
focusFirst,
focusLast,
} = stepperCtx
const { step, isDisabled } = useStepItem()
const isSelected = activeStep === step
const id = `stepper-tab-${step}`
const panelId = `stepper-panel-${step}`
// Register this trigger for keyboard navigation
const btnRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (btnRef.current) {
registerTrigger(btnRef.current)
}
}, [btnRef.current])
// Find our index among triggers for navigation
const myIdx = useMemo(
() =>
triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current),
[triggerNodes, btnRef.current]
)
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault()
if (myIdx !== -1 && focusNext) focusNext(myIdx)
break
case "ArrowLeft":
case "ArrowUp":
e.preventDefault()
if (myIdx !== -1 && focusPrev) focusPrev(myIdx)
break
case "Home":
e.preventDefault()
if (focusFirst) focusFirst()
break
case "End":
e.preventDefault()
if (focusLast) focusLast()
break
case "Enter":
case " ":
e.preventDefault()
setActiveStep(step)
break
}
}
if (asChild) {
return (
<span
data-slot="stepper-trigger"
data-state={state}
className={className}
>
{children}
</span>
)
}
return (
<button
ref={btnRef}
role="tab"
id={id}
aria-selected={isSelected}
aria-controls={panelId}
tabIndex={typeof tabIndex === "number" ? tabIndex : isSelected ? 0 : -1}
data-slot="stepper-trigger"
data-state={state}
data-loading={isLoading}
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 inline-flex cursor-pointer items-center outline-none focus-visible:z-10 focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-60",
"gap-2.5 rounded-full",
className
)}
onClick={() => setActiveStep(step)}
onKeyDown={handleKeyDown}
disabled={isDisabled}
{...props}
>
{children}
</button>
)
}
function StepperIndicator({
children,
className,
}: React.ComponentProps<"div">) {
const { state, isLoading } = useStepItem()
const { indicators } = useStepper()
return (
<div
data-slot="stepper-indicator"
data-state={state}
className={cn(
"border-background bg-accent text-accent-foreground data-[state=completed]:bg-primary data-[state=completed]:text-primary-foreground data-[state=active]:bg-primary data-[state=active]:text-primary-foreground relative flex size-6 shrink-0 items-center justify-center overflow-hidden",
"rounded-full text-xs",
className
)}
>
<div className="absolute">
{indicators &&
((isLoading && indicators.loading) ||
(state === "completed" && indicators.completed) ||
(state === "active" && indicators.active) ||
(state === "inactive" && indicators.inactive))
? (isLoading && indicators.loading) ||
(state === "completed" && indicators.completed) ||
(state === "active" && indicators.active) ||
(state === "inactive" && indicators.inactive)
: children}
</div>
</div>
)
}
function StepperSeparator({ className }: React.ComponentProps<"div">) {
const { state } = useStepItem()
return (
<div
data-slot="stepper-separator"
data-state={state}
className={cn(
"bg-muted rounded-sm group-data-[orientation=horizontal]/stepper-nav:h-0.5 group-data-[orientation=vertical]/stepper-nav:h-12 group-data-[orientation=vertical]/stepper-nav:w-0.5 m-0.5 group-data-[orientation=horizontal]/stepper-nav:flex-1",
className
)}
/>
)
}
function StepperTitle({ children, className }: React.ComponentProps<"h3">) {
const { state } = useStepItem()
return (
<h3
data-slot="stepper-title"
data-state={state}
className={cn(
"text-sm leading-none font-medium",
className
)}
>
{children}
</h3>
)
}
function StepperDescription({
children,
className,
}: React.ComponentProps<"div">) {
const { state } = useStepItem()
return (
<div
data-slot="stepper-description"
data-state={state}
className={cn(
"text-muted-foreground text-sm",
className
)}
>
{children}
</div>
)
}
function StepperNav({ children, className }: React.ComponentProps<"nav">) {
const { activeStep, orientation } = useStepper()
return (
<nav
data-slot="stepper-nav"
data-state={activeStep}
data-orientation={orientation}
className={cn(
"group/stepper-nav inline-flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
className
)}
>
{children}
</nav>
)
}
function StepperPanel({ children, className }: React.ComponentProps<"div">) {
const { activeStep } = useStepper()
return (
<div
data-slot="stepper-panel"
data-state={activeStep}
className={cn("w-full", className)}
>
{children}
</div>
)
}
interface StepperContentProps extends React.ComponentProps<"div"> {
value: number
forceMount?: boolean
}
function StepperContent({
value,
forceMount,
children,
className,
}: StepperContentProps) {
const { activeStep } = useStepper()
const isActive = value === activeStep
if (!forceMount && !isActive) {
return null
}
return (
<div
data-slot="stepper-content"
data-state={activeStep}
className={cn("w-full", className, !isActive && forceMount && "hidden")}
hidden={!isActive && forceMount}
>
{children}
</div>
)
}
export {
useStepper,
useStepItem,
Stepper,
StepperItem,
StepperTrigger,
StepperIndicator,
StepperSeparator,
StepperTitle,
StepperDescription,
StepperPanel,
StepperContent,
StepperNav,
type StepperProps,
type StepperItemProps,
type StepperTriggerProps,
type StepperContentProps,
}
+258
View File
@@ -0,0 +1,258 @@
"use client"
import { createContext, useCallback, useContext, useState } from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@/lib/utils"
// Types
type TimelineContextValue = {
activeStep: number
setActiveStep: (step: number) => void
}
// Context
const TimelineContext = createContext<TimelineContextValue | undefined>(
undefined
)
const useTimeline = () => {
const context = useContext(TimelineContext)
if (!context) {
throw new Error("useTimeline must be used within a Timeline")
}
return context
}
// Components
interface TimelineProps extends useRender.ComponentProps<"div"> {
defaultValue?: number
value?: number
onValueChange?: (value: number) => void
orientation?: "horizontal" | "vertical"
}
function Timeline({
defaultValue = 1,
value,
onValueChange,
orientation = "vertical",
className,
render,
children,
...props
}: TimelineProps) {
const [activeStep, setInternalStep] = useState(defaultValue)
const setActiveStep = useCallback(
(step: number) => {
if (value === undefined) {
setInternalStep(step)
}
onValueChange?.(step)
},
[value, onValueChange]
)
const currentStep = value ?? activeStep
const defaultProps = {
className: cn(
"group/timeline flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
className
),
"data-orientation": orientation,
"data-slot": "timeline",
children,
}
return (
<TimelineContext.Provider
value={{ activeStep: currentStep, setActiveStep }}
>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</TimelineContext.Provider>
)
}
// TimelineContent
function TimelineContent({
className,
render,
children,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
className: cn("text-muted-foreground text-sm", className),
"data-slot": "timeline-content",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineDate
type TimelineDateProps = useRender.ComponentProps<"time">
function TimelineDate({
className,
render,
children,
...props
}: TimelineDateProps) {
const defaultProps = {
className: cn(
"mb-1 block font-medium text-muted-foreground text-xs group-data-[orientation=vertical]/timeline:max-sm:h-4",
className
),
"data-slot": "timeline-date",
children,
}
return useRender({
defaultTagName: "time",
render,
props: mergeProps<"time">(defaultProps, props),
})
}
// TimelineHeader
function TimelineHeader({
className,
render,
children,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
className: cn(className),
"data-slot": "timeline-header",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineIndicator
type TimelineIndicatorProps = useRender.ComponentProps<"div">
function TimelineIndicator({
className,
children,
render,
...props
}: TimelineIndicatorProps) {
const defaultProps = {
"aria-hidden": true,
className: cn(
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute size-4 rounded-full border-2 border-primary/20 group-data-[orientation=vertical]/timeline:top-0 group-data-[orientation=horizontal]/timeline:left-0 group-data-completed/timeline-item:border-primary",
className
),
"data-slot": "timeline-indicator",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineItem
interface TimelineItemProps extends useRender.ComponentProps<"div"> {
step: number
}
function TimelineItem({
step,
className,
render,
children,
...props
}: TimelineItemProps) {
const { activeStep } = useTimeline()
const defaultProps = {
className: cn(
"group/timeline-item relative flex flex-1 flex-col gap-0.5 group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=horizontal]/timeline:mt-8 group-data-[orientation=horizontal]/timeline:not-last:pe-8 group-data-[orientation=vertical]/timeline:not-last:pb-6 has-[+[data-completed]]:**:data-[slot=timeline-separator]:bg-primary",
className
),
"data-completed": step <= activeStep || undefined,
"data-slot": "timeline-item",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineSeparator
function TimelineSeparator({
className,
render,
children,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
"aria-hidden": true,
className: cn(
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute self-start bg-primary/10 group-last/timeline-item:hidden group-data-[orientation=horizontal]/timeline:h-0.5 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem-0.25rem)] group-data-[orientation=horizontal]/timeline:w-[calc(100%-1rem-0.25rem)] group-data-[orientation=vertical]/timeline:w-0.5 group-data-[orientation=horizontal]/timeline:translate-x-4.5 group-data-[orientation=vertical]/timeline:translate-y-4.5",
className
),
"data-slot": "timeline-separator",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineTitle
function TimelineTitle({
className,
render,
children,
...props
}: useRender.ComponentProps<"h3">) {
const defaultProps = {
className: cn("font-medium text-sm", className),
"data-slot": "timeline-title",
children,
}
return useRender({
defaultTagName: "h3",
render,
props: mergeProps<"h3">(defaultProps, props),
})
}
export {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
}
+87
View File
@@ -0,0 +1,87 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
vertical:
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
),
},
props
),
render,
state: {
slot: "button-group-text",
},
})
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}

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