Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1a9124f3d | ||
|
|
009011a917 | ||
|
|
399871f4f9 | ||
|
|
158fc36294 | ||
|
|
7dc4836c71 | ||
|
|
efc3812e12 | ||
|
|
8d5fd84962 |
@@ -0,0 +1,144 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
path = Path(__file__).resolve().parents[2] / "app" / "(main)" / "servers" / "page.tsx"
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
'import { Fragment, useEffect, useMemo, useState } from "react"\n'
|
||||||
|
'import { PageHeader } from "@/components/page-header"\n'
|
||||||
|
'import { StatusBadge } from "@/components/status-badge"',
|
||||||
|
'import { useEffect, useMemo, useState } from "react"\n'
|
||||||
|
'import { PageHeader } from "@/components/page-header"\n'
|
||||||
|
'import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"\n'
|
||||||
|
'import { DataPageToolbar } from "@/components/data-page-toolbar"\n'
|
||||||
|
'import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
"""import {
|
||||||
|
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||||
|
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
""",
|
||||||
|
"""import {
|
||||||
|
Stepper,
|
||||||
|
StepperContent,
|
||||||
|
StepperIndicator,
|
||||||
|
StepperItem,
|
||||||
|
StepperNav,
|
||||||
|
StepperPanel,
|
||||||
|
StepperSeparator,
|
||||||
|
StepperTitle,
|
||||||
|
StepperTrigger,
|
||||||
|
} from "@/components/reui/stepper"
|
||||||
|
""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
"""import {
|
||||||
|
SearchIcon, RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
||||||
|
MoreHorizontalIcon, EyeIcon, EyeOffIcon,
|
||||||
|
ChevronRightIcon, ChevronDownIcon,
|
||||||
|
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
||||||
|
ShieldIcon, WifiIcon, PencilIcon, PowerIcon, Trash2Icon, ExternalLinkIcon,
|
||||||
|
HomeIcon, ServerIcon, NetworkIcon,
|
||||||
|
} from "lucide-react\"""",
|
||||||
|
"""import {
|
||||||
|
RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
||||||
|
EyeIcon, EyeOffIcon,
|
||||||
|
ChevronRightIcon, ChevronDownIcon,
|
||||||
|
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
||||||
|
ShieldIcon, WifiIcon,
|
||||||
|
HomeIcon, ServerIcon, NetworkIcon,
|
||||||
|
} from "lucide-react\"""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r"// ─── RouterOS version utilities.*?// ─── Countries ─",
|
||||||
|
"// ─── Countries ─",
|
||||||
|
text,
|
||||||
|
count=1,
|
||||||
|
flags=re.S,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r"// ─── Type config ─.*?// ─── Shared small components ─",
|
||||||
|
"// ─── Shared small components ─",
|
||||||
|
text,
|
||||||
|
count=1,
|
||||||
|
flags=re.S,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r"function Field\(\{ label, hint, required, children \}:.*?^}\n\n// ─── Country field",
|
||||||
|
"// ─── Country field",
|
||||||
|
text,
|
||||||
|
count=1,
|
||||||
|
flags=re.S | re.M,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
"const [expandedId, setExpandedId] = useState<string | null>(null)",
|
||||||
|
"const [sheetStep, setSheetStep] = useState(1)",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
'setForm(defaultForm); setTestState("idle"); setTestMsg("")\n setOpen(true)',
|
||||||
|
'setForm(defaultForm); setTestState("idle"); setTestMsg("")\n setSheetStep(1)\n setOpen(true)',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
'setTestState("idle"); setTestMsg(""); setOpen(true)',
|
||||||
|
'setTestState("idle"); setTestMsg(""); setSheetStep(1); setOpen(true)',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = re.sub(r"<Field\b", "<FormField", text)
|
||||||
|
text = re.sub(r"</Field>", "</FormField>", text)
|
||||||
|
text = re.sub(r"<Toggle\b", "<FormToggle", text)
|
||||||
|
|
||||||
|
new_table = """ {/* Table */}
|
||||||
|
<Card>
|
||||||
|
<DataPageToolbar
|
||||||
|
segmented={{
|
||||||
|
value: typeFilter,
|
||||||
|
onChange: setTypeFilter,
|
||||||
|
options: tabs.map((tab) => ({
|
||||||
|
value: tab.value,
|
||||||
|
label: tab.label,
|
||||||
|
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
|
||||||
|
})),
|
||||||
|
}}
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder="Поиск по имени, хосту…"
|
||||||
|
countLabel={`${filtered.length} серверов`}
|
||||||
|
/>
|
||||||
|
<ServersDataGrid
|
||||||
|
servers={filtered}
|
||||||
|
isLive={isLive}
|
||||||
|
pollingIds={pollingIds}
|
||||||
|
onPoll={handlePoll}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onToggleStatus={handleToggleStatus}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
"""
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r" \{/\* Table \*/\}\n <Card>.*?</Card>\n",
|
||||||
|
new_table,
|
||||||
|
text,
|
||||||
|
count=1,
|
||||||
|
flags=re.S,
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
print("patched", path)
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
path = Path(__file__).resolve().parents[2] / "app" / "(main)" / "servers" / "page.tsx"
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Wrap sheet form in stepper
|
||||||
|
text = text.replace(
|
||||||
|
""" <div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||||
|
|
||||||
|
{/* 1. Основные */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Основные</SectionTitle>""",
|
||||||
|
""" <Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||||
|
<StepperNav className="mb-5">
|
||||||
|
<StepperItem step={1}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>1</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">Основные</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={2}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>2</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">WAN</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={3}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>3</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">API</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={4}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>4</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">Дополнительно</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
</StepperItem>
|
||||||
|
</StepperNav>
|
||||||
|
<StepperPanel className="flex-1 overflow-y-auto">
|
||||||
|
<StepperContent value={1} className="flex flex-col gap-4">""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
""" </div>
|
||||||
|
|
||||||
|
{/* 2. WAN-аплинки (только для home-router) */}
|
||||||
|
{isHomeRouter && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>WAN-аплинки</SectionTitle>""",
|
||||||
|
""" </StepperContent>
|
||||||
|
<StepperContent value={2} className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>WAN-аплинки</SectionTitle>""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
""" </div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 3. Подключение (API) */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>""",
|
||||||
|
""" </StepperContent>
|
||||||
|
<StepperContent value={3} className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
""" </div>
|
||||||
|
|
||||||
|
{/* 4. Дополнительно */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}""",
|
||||||
|
""" </StepperContent>
|
||||||
|
<StepperContent value={4} className="flex flex-col gap-4">
|
||||||
|
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
""" </div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
|
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||||||
|
<Button className="flex-1" onClick={handleSave}>
|
||||||
|
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>""",
|
||||||
|
""" </StepperContent>
|
||||||
|
</StepperPanel>
|
||||||
|
</Stepper>
|
||||||
|
|
||||||
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
|
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
|
||||||
|
{sheetStep > 1 && (
|
||||||
|
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
|
||||||
|
)}
|
||||||
|
{sheetStep < 4 ? (
|
||||||
|
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
|
||||||
|
) : (
|
||||||
|
<Button className="ml-auto" onClick={handleSave}>
|
||||||
|
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</SheetFooter>""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# WAN step 2: show message when not home router
|
||||||
|
text = text.replace(
|
||||||
|
""" <StepperContent value={2} className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>WAN-аплинки</SectionTitle>
|
||||||
|
<WanUplinkEditor""",
|
||||||
|
""" <StepperContent value={2} className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>WAN-аплинки</SectionTitle>
|
||||||
|
{!isHomeRouter ? (
|
||||||
|
<p className="text-sm text-muted-foreground">WAN-аплинки доступны только для типа Home Router.</p>
|
||||||
|
) : (
|
||||||
|
<WanUplinkEditor""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
""" />
|
||||||
|
</StepperContent>
|
||||||
|
<StepperContent value={3}""",
|
||||||
|
""" />
|
||||||
|
)}
|
||||||
|
</StepperContent>
|
||||||
|
<StepperContent value={3}""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
print("stepper patched")
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# CodeGraph data files
|
||||||
|
# These are local to each machine and should not be committed
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
cache/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Hook markers
|
||||||
|
.dirty
|
||||||
@@ -5,15 +5,40 @@ alwaysApply: true
|
|||||||
|
|
||||||
# Локальный запуск проекта
|
# Локальный запуск проекта
|
||||||
|
|
||||||
Для запуска всего проекта в dev-режиме поднимать два процесса:
|
## Требования
|
||||||
|
|
||||||
|
- **Node.js 22**, npm с workspaces.
|
||||||
|
- Первый раз (или после смены зависимостей): `npm install` из корня репозитория.
|
||||||
|
- Backend: скопировать `backend/.env.example` → `backend/.env` (по умолчанию `PORT=8000`, `CORS_ORIGIN=http://localhost:3000`).
|
||||||
|
|
||||||
|
## Запуск (два процесса)
|
||||||
|
|
||||||
|
Из корня репозитория поднять **два** long-running процесса в **отдельных** терминалах:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm run dev
|
npm run dev
|
||||||
npm --prefix backend run dev
|
npm --prefix backend run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
- Frontend: `http://localhost:3000`
|
| Сервис | URL | Проверка |
|
||||||
- Backend: `http://localhost:8000`
|
|--------|-----|----------|
|
||||||
- Health check backend: `http://localhost:8000/health`
|
| Frontend (Next.js 16, Turbopack) | http://localhost:3000 | открыть в браузере |
|
||||||
|
| Backend (Fastify) | http://localhost:8000 | `GET /health` |
|
||||||
|
|
||||||
Если пользователь просит “запусти проект”, “запусти фронт и бэк” или похожую команду, сначала проверь уже запущенные терминалы, затем запускай эти две команды отдельными long-running процессами.
|
Проверка backend в PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri http://localhost:8000/health -UseBasicParsing | Select-Object -ExpandProperty Content
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидаемый ответ: `{"status":"ok",...}`.
|
||||||
|
|
||||||
|
## Поведение агента
|
||||||
|
|
||||||
|
Если пользователь просит «запусти проект», «запусти фронт и бэк» или похожее:
|
||||||
|
|
||||||
|
1. Сначала проверить уже запущенные терминалы — не дублировать процессы.
|
||||||
|
2. Запустить обе команды выше как фоновые long-running процессы.
|
||||||
|
3. Дождаться готовности: frontend — `Ready`, backend — `Server listening` / успешный `/health`.
|
||||||
|
|
||||||
|
Подробности архитектуры и env — `README.md`, раздел «Запуск».
|
||||||
|
|||||||
@@ -10,3 +10,7 @@
|
|||||||
4. Локальный hook `.githooks/commit-msg` отклоняет subject без кириллицы; подключение — `npm install` / `npm run prepare`.
|
4. Локальный hook `.githooks/commit-msg` отклоняет subject без кириллицы; подключение — `npm install` / `npm run prepare`.
|
||||||
|
|
||||||
Полные правила: `.cursor/rules/commit-messages-ru.mdc`, semver — `.cursor/rules/release-versioning.mdc`.
|
Полные правила: `.cursor/rules/commit-messages-ru.mdc`, semver — `.cursor/rules/release-versioning.mdc`.
|
||||||
|
|
||||||
|
## Локальный запуск
|
||||||
|
|
||||||
|
Два процесса из корня: `npm run dev` (frontend :3000) и `npm --prefix backend run dev` (backend :8000). Перед первым запуском — `npm install`, для backend — `backend/.env` из `backend/.env.example`. Подробности — `.cursor/rules/dev-run-command.mdc` и `README.md`.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
@@ -685,23 +686,6 @@ const INIT_TG: TelegramConfig = {
|
|||||||
|
|
||||||
// ─── small helpers ────────────────────────────────────────────────────────────
|
// ─── small helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
|
|
||||||
return (
|
|
||||||
<button role="switch" aria-checked={checked} aria-disabled={disabled} disabled={disabled}
|
|
||||||
onClick={() => { if (!disabled) onChange(!checked) }}
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
|
|
||||||
disabled && "opacity-50 pointer-events-none",
|
|
||||||
checked ? "bg-primary" : "bg-muted-foreground/30",
|
|
||||||
)}>
|
|
||||||
<span className={cn(
|
|
||||||
"inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform",
|
|
||||||
checked ? "translate-x-4" : "translate-x-0.5",
|
|
||||||
)} />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldLabel({ children, className }: { children: React.ReactNode; className?: string }) {
|
function FieldLabel({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||||
return <p className={cn("text-sm font-medium mb-1.5 leading-none", className)}>{children}</p>
|
return <p className={cn("text-sm font-medium mb-1.5 leading-none", className)}>{children}</p>
|
||||||
}
|
}
|
||||||
@@ -739,7 +723,7 @@ function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled }
|
|||||||
!rule.enabled && "opacity-55",
|
!rule.enabled && "opacity-55",
|
||||||
)}>
|
)}>
|
||||||
{/* toggle */}
|
{/* toggle */}
|
||||||
<Toggle checked={rule.enabled} onChange={v => onToggle(rule.id, v)} disabled={interactionsDisabled} />
|
<FormToggle checked={rule.enabled} onChange={v => onToggle(rule.id, v)} disabled={interactionsDisabled} />
|
||||||
|
|
||||||
{/* severity dot */}
|
{/* severity dot */}
|
||||||
<SeverityDot severity={rule.severity} />
|
<SeverityDot severity={rule.severity} />
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { DataTable } from "@/components/data-table"
|
import { DataTable } from "@/components/data-table"
|
||||||
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import { asns as mockAsns } from "@/lib/data"
|
import { asns as mockAsns } from "@/lib/data"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function AsnsPage() {
|
export default function AsnsPage() {
|
||||||
const { mode } = useDataSource()
|
const { mode } = useDataSource()
|
||||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||||
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
|
|
||||||
const useEvoCatalog = mode === "live" && enabled
|
const useEvoCatalog = mode === "live" && enabled
|
||||||
|
|
||||||
@@ -28,7 +31,9 @@ export default function AsnsPage() {
|
|||||||
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
|
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
|
<UploadIcon className="size-4" />Импорт
|
||||||
|
</Button>
|
||||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||||
<Button size="sm"><PlusIcon className="size-4" />Добавить ASN</Button>
|
<Button size="sm"><PlusIcon className="size-4" />Добавить ASN</Button>
|
||||||
</>
|
</>
|
||||||
@@ -57,6 +62,7 @@ export default function AsnsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
data={rows}
|
data={rows}
|
||||||
|
isLoading={useEvoCatalog && loading && !snapshot}
|
||||||
searchPlaceholder="Поиск по ASN, имени, префиксам…"
|
searchPlaceholder="Поиск по ASN, имени, префиксам…"
|
||||||
searchKeys={["asn", "org", "prefixes"]}
|
searchKeys={["asn", "org", "prefixes"]}
|
||||||
columns={[
|
columns={[
|
||||||
@@ -106,6 +112,16 @@ export default function AsnsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FileImportDialog
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
title="Импорт ASN"
|
||||||
|
description="Загрузите CSV или JSON со списком автономных систем"
|
||||||
|
accept=".csv,.json,text/csv,application/json"
|
||||||
|
onImport={async (files) => {
|
||||||
|
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+178
-202
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
|
import { BackupsDataGrid } from "@/components/data-grids/backups-data-grid"
|
||||||
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
|
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
||||||
import { StatusBadge } from "@/components/status-badge"
|
import { StatusBadge } from "@/components/status-badge"
|
||||||
import type { Backup, Server } from "@/lib/data"
|
import type { Backup, Server } from "@/lib/data"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
@@ -22,52 +26,17 @@ import { listServers } from "@/shared/api/servers"
|
|||||||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||||
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
|
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
// ─── small UI helpers ─────────────────────────────────────────────────────────
|
Stepper,
|
||||||
|
StepperContent,
|
||||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
StepperIndicator,
|
||||||
return (
|
StepperItem,
|
||||||
<button type="button" role="switch" aria-checked={checked} onClick={() => onChange(!checked)}
|
StepperNav,
|
||||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors ${checked ? "bg-primary" : "bg-input"}`}>
|
StepperPanel,
|
||||||
<span className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-4" : "translate-x-0"}`} />
|
StepperSeparator,
|
||||||
</button>
|
StepperTitle,
|
||||||
)
|
StepperTrigger,
|
||||||
}
|
} from "@/components/reui/stepper"
|
||||||
|
|
||||||
function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 pb-1">
|
|
||||||
<span className="text-muted-foreground">{icon}</span>
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
|
|
||||||
<div className="flex-1 h-px bg-border" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-sm font-medium">{label}</label>
|
|
||||||
{children}
|
|
||||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SegmentedControl<T extends string>({ value, onChange, options }: {
|
|
||||||
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
|
|
||||||
{options.map((o) => (
|
|
||||||
<button key={o.value} type="button" onClick={() => onChange(o.value)}
|
|
||||||
className={`px-3 py-1 text-sm rounded transition-colors ${value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
|
|
||||||
{o.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -168,8 +137,11 @@ export default function BackupsPage() {
|
|||||||
|
|
||||||
// Manual backup sheet
|
// Manual backup sheet
|
||||||
const [manualOpen, setManualOpen] = useState(false)
|
const [manualOpen, setManualOpen] = useState(false)
|
||||||
|
const [manualStep, setManualStep] = useState(1)
|
||||||
const [manualServers, setManualServers] = useState<Set<string>>(new Set())
|
const [manualServers, setManualServers] = useState<Set<string>>(new Set())
|
||||||
const [manualNotes, setManualNotes] = useState("")
|
const [manualNotes, setManualNotes] = useState("")
|
||||||
|
const [restoreOpen, setRestoreOpen] = useState(false)
|
||||||
|
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null)
|
||||||
function toggleManualServer(id: string) {
|
function toggleManualServer(id: string) {
|
||||||
setManualServers((prev) => {
|
setManualServers((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@@ -356,7 +328,7 @@ export default function BackupsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualOpen(true) }}
|
onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualStep(1); setManualOpen(true) }}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-4" />Новый бэкап
|
<PlusIcon className="size-4" />Новый бэкап
|
||||||
@@ -425,81 +397,27 @@ export default function BackupsPage() {
|
|||||||
{/* ── История ──────────────────────────────────────────────────── */}
|
{/* ── История ──────────────────────────────────────────────────── */}
|
||||||
{tab === "history" && (
|
{tab === "history" && (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
<DataPageToolbar
|
||||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
segmented={{
|
||||||
{([
|
value: kindFilter,
|
||||||
{ value: "all", label: "Все", count: backupList.length },
|
onChange: setKindFilter,
|
||||||
{ value: "auto", label: "Авто", count: autoCount },
|
options: [
|
||||||
{ value: "manual", label: "Вручную", count: manualCount },
|
{ value: "all", label: "Все", count: backupList.length },
|
||||||
] as { value: KindFilter; label: string; count: number }[]).map((t) => (
|
{ value: "auto", label: "Авто", count: autoCount },
|
||||||
<button key={t.value} onClick={() => setKindFilter(t.value)}
|
{ value: "manual", label: "Вручную", count: manualCount },
|
||||||
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>
|
countLabel={`${filtered.length} бэкапов`}
|
||||||
</button>
|
/>
|
||||||
))}
|
<BackupsDataGrid
|
||||||
</div>
|
backups={filtered}
|
||||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} бэкапов</span>
|
onDownload={handleDownload}
|
||||||
</div>
|
onRestore={(b) => {
|
||||||
|
setRestoreTarget(b)
|
||||||
<div className="overflow-x-auto">
|
setRestoreOpen(true)
|
||||||
<table className="w-full text-sm">
|
}}
|
||||||
<thead>
|
onDelete={handleDelete}
|
||||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
/>
|
||||||
<th className="text-left font-medium px-5 py-3">Файл</th>
|
|
||||||
<th className="text-left font-medium px-4 py-3">Сервер</th>
|
|
||||||
<th className="text-left font-medium px-4 py-3">Размер</th>
|
|
||||||
<th className="text-left font-medium px-4 py-3">Тип</th>
|
|
||||||
<th className="text-left font-medium px-4 py-3">Заметки</th>
|
|
||||||
<th className="text-left font-medium px-4 py-3">Создан</th>
|
|
||||||
<th className="w-28 px-3 py-3" />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<tr><td colSpan={7} className="px-5 py-10 text-center text-sm text-muted-foreground">Нет бэкапов</td></tr>
|
|
||||||
)}
|
|
||||||
{filtered.map((b) => (
|
|
||||||
<tr key={b.id} className="hover:bg-muted/40 transition-colors group">
|
|
||||||
<td className="px-5 py-3 font-mono text-xs font-medium">{b.filename}</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-muted-foreground">{b.server}</td>
|
|
||||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{b.size}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className={cn("text-xs px-2 py-0.5 rounded border font-medium",
|
|
||||||
b.kind === "manual"
|
|
||||||
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
|
|
||||||
: "bg-muted text-muted-foreground border-border"
|
|
||||||
)}>
|
|
||||||
{b.kind === "auto" ? "авто" : "вручную"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-xs text-muted-foreground max-w-[200px] truncate">{b.notes || "—"}</td>
|
|
||||||
<td className="px-4 py-3 text-xs text-muted-foreground">{b.created}</td>
|
|
||||||
<td className="px-3 py-3">
|
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-7"
|
|
||||||
title="Скачать"
|
|
||||||
onClick={() => void handleDownload(b.id, b.filename)}
|
|
||||||
>
|
|
||||||
<DownloadIcon className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" className="size-7" title="Восстановить">
|
|
||||||
<RefreshCwIcon className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" className="size-7 text-destructive hover:text-destructive"
|
|
||||||
title="Удалить" onClick={() => void handleDelete(b.id)}>
|
|
||||||
<Trash2Icon className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -517,11 +435,11 @@ export default function BackupsPage() {
|
|||||||
<p className="text-sm font-medium">Автоматический бэкап</p>
|
<p className="text-sm font-medium">Автоматический бэкап</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
|
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={schedule.enabled} onChange={(v) => setSched("enabled", v)} />
|
<FormToggle checked={schedule.enabled} onChange={(v) => setSched("enabled", v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn("flex flex-col gap-4 transition-opacity", !schedule.enabled && "opacity-40 pointer-events-none")}>
|
<div className={cn("flex flex-col gap-4 transition-opacity", !schedule.enabled && "opacity-40 pointer-events-none")}>
|
||||||
<Field label="Частота">
|
<FormField label="Частота">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={schedule.frequency}
|
value={schedule.frequency}
|
||||||
onChange={(v) => setSched("frequency", v)}
|
onChange={(v) => setSched("frequency", v)}
|
||||||
@@ -531,10 +449,10 @@ export default function BackupsPage() {
|
|||||||
{ value: "monthly", label: "Ежемесячно" },
|
{ value: "monthly", label: "Ежемесячно" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
{schedule.frequency === "weekly" && (
|
{schedule.frequency === "weekly" && (
|
||||||
<Field label="День недели">
|
<FormField label="День недели">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{WEEK_DAYS.map((d, i) => (
|
{WEEK_DAYS.map((d, i) => (
|
||||||
<button key={i} type="button" onClick={() => setSched("weekDay", i)}
|
<button key={i} type="button" onClick={() => setSched("weekDay", i)}
|
||||||
@@ -548,18 +466,18 @@ export default function BackupsPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{schedule.frequency === "monthly" && (
|
{schedule.frequency === "monthly" && (
|
||||||
<Field label="День месяца" hint="1–28">
|
<FormField label="День месяца" hint="1–28">
|
||||||
<Input type="number" min={1} max={28} className="font-mono w-24"
|
<Input type="number" min={1} max={28} className="font-mono w-24"
|
||||||
value={schedule.monthDay}
|
value={schedule.monthDay}
|
||||||
onChange={(e) => setSched("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))} />
|
onChange={(e) => setSched("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))} />
|
||||||
</Field>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Field label="Время запуска">
|
<FormField label="Время запуска">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input type="number" min={0} max={23} className="font-mono w-20 text-center"
|
<Input type="number" min={0} max={23} className="font-mono w-20 text-center"
|
||||||
@@ -581,15 +499,15 @@ export default function BackupsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field label="Хранить бэкапов" hint="На каждый сервер">
|
<FormField label="Хранить бэкапов" hint="На каждый сервер">
|
||||||
<Input type="number" min={1} max={90} className="font-mono"
|
<Input type="number" min={1} max={90} className="font-mono"
|
||||||
value={schedule.keepCount}
|
value={schedule.keepCount}
|
||||||
onChange={(e) => setSched("keepCount", Math.max(1, Number(e.target.value)))} />
|
onChange={(e) => setSched("keepCount", Math.max(1, Number(e.target.value)))} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Формат файла">
|
<FormField label="Формат файла">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={schedule.format}
|
value={schedule.format}
|
||||||
onChange={(v) => setSched("format", v)}
|
onChange={(v) => setSched("format", v)}
|
||||||
@@ -598,7 +516,7 @@ export default function BackupsPage() {
|
|||||||
{ value: "backup", label: ".backup" },
|
{ value: "backup", label: ".backup" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -609,7 +527,7 @@ export default function BackupsPage() {
|
|||||||
<CardContent className="px-5 py-5 flex flex-col gap-5">
|
<CardContent className="px-5 py-5 flex flex-col gap-5">
|
||||||
<SectionTitle icon={<FolderIcon className="size-3.5" />}>Хранилище</SectionTitle>
|
<SectionTitle icon={<FolderIcon className="size-3.5" />}>Хранилище</SectionTitle>
|
||||||
|
|
||||||
<Field label="Тип хранилища">
|
<FormField label="Тип хранилища">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={storage.type}
|
value={storage.type}
|
||||||
onChange={(v) => setStore("type", v)}
|
onChange={(v) => setStore("type", v)}
|
||||||
@@ -620,45 +538,45 @@ export default function BackupsPage() {
|
|||||||
{ value: "smb", label: "SMB" },
|
{ value: "smb", label: "SMB" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
{storage.type === "local" && (
|
{storage.type === "local" && (
|
||||||
<Field label="Путь сохранения" hint="Директория на сервере приложения">
|
<FormField label="Путь сохранения" hint="Директория на сервере приложения">
|
||||||
<Input className="font-mono" placeholder="/var/backup/mikrotik"
|
<Input className="font-mono" placeholder="/var/backup/mikrotik"
|
||||||
value={storage.localPath}
|
value={storage.localPath}
|
||||||
onChange={(e) => setStore("localPath", e.target.value)} />
|
onChange={(e) => setStore("localPath", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{storage.type !== "local" && (
|
{storage.type !== "local" && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<Field label="Хост">
|
<FormField label="Хост">
|
||||||
<Input className="font-mono" placeholder="192.168.1.100"
|
<Input className="font-mono" placeholder="192.168.1.100"
|
||||||
value={storage.host} onChange={(e) => setStore("host", e.target.value)} />
|
value={storage.host} onChange={(e) => setStore("host", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<Field label="Порт">
|
<FormField label="Порт">
|
||||||
<Input className="font-mono"
|
<Input className="font-mono"
|
||||||
placeholder={storage.type === "ftp" ? "21" : storage.type === "scp" ? "22" : "445"}
|
placeholder={storage.type === "ftp" ? "21" : storage.type === "scp" ? "22" : "445"}
|
||||||
value={storage.port} onChange={(e) => setStore("port", e.target.value)} />
|
value={storage.port} onChange={(e) => setStore("port", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{storage.type === "smb" && (
|
{storage.type === "smb" && (
|
||||||
<Field label="Общая папка (Share)">
|
<FormField label="Общая папка (Share)">
|
||||||
<Input className="font-mono" placeholder="backups"
|
<Input className="font-mono" placeholder="backups"
|
||||||
value={storage.share} onChange={(e) => setStore("share", e.target.value)} />
|
value={storage.share} onChange={(e) => setStore("share", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Пользователь">
|
<FormField label="Пользователь">
|
||||||
<Input className="font-mono" placeholder="backup-user"
|
<Input className="font-mono" placeholder="backup-user"
|
||||||
value={storage.username} onChange={(e) => setStore("username", e.target.value)} />
|
value={storage.username} onChange={(e) => setStore("username", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label={storage.type === "scp" ? "Пароль / ключ" : "Пароль"}>
|
<FormField label={storage.type === "scp" ? "Пароль / ключ" : "Пароль"}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
type={storage.showPassword ? "text" : "password"}
|
type={storage.showPassword ? "text" : "password"}
|
||||||
@@ -673,13 +591,13 @@ export default function BackupsPage() {
|
|||||||
{storage.showPassword ? "скрыть" : "показ"}
|
{storage.showPassword ? "скрыть" : "показ"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Удалённый путь">
|
<FormField label="Удалённый путь">
|
||||||
<Input className="font-mono" placeholder="/mikrotik-backups"
|
<Input className="font-mono" placeholder="/mikrotik-backups"
|
||||||
value={storage.remotePath} onChange={(e) => setStore("remotePath", e.target.value)} />
|
value={storage.remotePath} onChange={(e) => setStore("remotePath", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -759,71 +677,129 @@ export default function BackupsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ══ Sheet: Manual backup ══════════════════════════════════════════════ */}
|
{/* ══ Sheet: Manual backup ══════════════════════════════════════════════ */}
|
||||||
<Sheet open={manualOpen} onOpenChange={setManualOpen}>
|
<Sheet open={manualOpen} onOpenChange={(v) => { setManualOpen(v); if (!v) setManualStep(1) }}>
|
||||||
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
<SheetTitle>Новый бэкап</SheetTitle>
|
<SheetTitle>Новый бэкап</SheetTitle>
|
||||||
<SheetDescription>Снять конфигурацию вручную с выбранных серверов</SheetDescription>
|
<SheetDescription>Снять конфигурацию вручную с выбранных серверов</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
<Stepper value={manualStep} onValueChange={setManualStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||||
<div className="flex flex-col gap-3">
|
<StepperNav className="mb-5">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<StepperItem step={1}>
|
||||||
<p className="text-sm font-medium">Выберите серверы</p>
|
<StepperTrigger>
|
||||||
<div className="flex items-center gap-2">
|
<StepperIndicator>1</StepperIndicator>
|
||||||
<button type="button" onClick={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
|
<StepperTitle className="sr-only">Серверы</StepperTitle>
|
||||||
className="text-xs text-primary hover:underline">Все</button>
|
</StepperTrigger>
|
||||||
<span className="text-border">·</span>
|
<StepperSeparator />
|
||||||
<button type="button" onClick={() => setManualServers(new Set())}
|
</StepperItem>
|
||||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
|
<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>
|
||||||
</div>
|
{liveServers.map((s) => {
|
||||||
{liveServers.map((s) => {
|
const checked = manualServers.has(s.id)
|
||||||
const checked = manualServers.has(s.id)
|
return (
|
||||||
return (
|
<button key={s.id} type="button" onClick={() => toggleManualServer(s.id)}
|
||||||
<button key={s.id} type="button" onClick={() => toggleManualServer(s.id)}
|
className={cn(
|
||||||
className={cn(
|
"flex items-center gap-3 rounded-lg border p-3 text-left transition-colors",
|
||||||
"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"
|
||||||
checked ? "border-primary/40 bg-primary/5" : "border-border hover:bg-muted/40"
|
)}>
|
||||||
)}>
|
<div className={cn(
|
||||||
<div className={cn(
|
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
|
||||||
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
|
checked ? "bg-primary border-primary" : "border-border"
|
||||||
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>}
|
||||||
{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} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex-1 min-w-0">
|
||||||
{s.status === "offline" && (
|
<p className="text-sm font-medium">{s.name}</p>
|
||||||
<span className="text-xs text-muted-foreground">недоступен</span>
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
)}
|
<span className="text-xs font-mono text-muted-foreground">{s.host}</span>
|
||||||
</button>
|
<StatusBadge status={s.status} />
|
||||||
)
|
</div>
|
||||||
})}
|
</div>
|
||||||
</div>
|
{s.status === "offline" && (
|
||||||
|
<span className="text-xs text-muted-foreground">недоступен</span>
|
||||||
<div className="flex flex-col gap-1.5">
|
)}
|
||||||
<label className="text-sm font-medium">Заметка</label>
|
</button>
|
||||||
<Input placeholder="Например: перед обновлением BGP"
|
)
|
||||||
value={manualNotes} onChange={(e) => setManualNotes(e.target.value)} />
|
})}
|
||||||
</div>
|
</StepperContent>
|
||||||
</div>
|
<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">
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||||||
<Button className="flex-1"
|
{manualStep > 1 && (
|
||||||
disabled={manualServers.size === 0 || backupJobId !== null}
|
<Button variant="outline" className="flex-1" onClick={() => setManualStep((s) => s - 1)}>
|
||||||
onClick={handleManualBackup}>
|
Назад
|
||||||
Снять бэкап ({manualServers.size})
|
</Button>
|
||||||
</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>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
|
<FileImportDialog
|
||||||
|
open={restoreOpen}
|
||||||
|
onOpenChange={setRestoreOpen}
|
||||||
|
title={restoreTarget ? `Восстановление: ${restoreTarget.filename}` : "Восстановление бэкапа"}
|
||||||
|
description="Выберите файл конфигурации для загрузки на роутер"
|
||||||
|
accept=".backup,.rsc,.zip"
|
||||||
|
onImport={async (files) => {
|
||||||
|
toast.success(`Файл ${files[0]?.name} подготовлен к восстановлению на ${restoreTarget?.server ?? "сервер"}`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+75
-291
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
import { Fragment, useState, useMemo, useEffect } from "react"
|
import { Fragment, useState, useMemo, useEffect } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
|
import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
|
||||||
|
import type { Filter } from "@/components/reui/filters"
|
||||||
|
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
|
||||||
|
import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
|
||||||
|
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
|
||||||
|
import { BGP_AS_NAMES } from "@/lib/bgp/types"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -17,49 +24,12 @@ import { useDataSource } from "@/lib/data-source"
|
|||||||
|
|
||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type BgpState = "Established" | "Active" | "Idle" | "Connect" | "OpenSent" | "OpenConfirm"
|
type BgpSession = BgpSessionRow
|
||||||
type BgpType = "eBGP" | "iBGP"
|
|
||||||
type BgpAfi = "IPv4 Unicast" | "IPv6 Unicast" | "VPNv4 Unicast"
|
|
||||||
type BgpTab = "sessions" | "routers" | "analytics"
|
type BgpTab = "sessions" | "routers" | "analytics"
|
||||||
type StateFilter = "all" | BgpState
|
type StateFilter = "all" | BgpState
|
||||||
type TypeFilter = "all" | BgpType
|
type TypeFilter = "all" | BgpType
|
||||||
|
|
||||||
interface BgpSession {
|
const AS_NAMES = BGP_AS_NAMES
|
||||||
id: string
|
|
||||||
serverId: string
|
|
||||||
serverLabel: string
|
|
||||||
serverSite: string
|
|
||||||
peerIp: string
|
|
||||||
remoteAs: number
|
|
||||||
localAs: number
|
|
||||||
routerId: string
|
|
||||||
description: string
|
|
||||||
state: BgpState
|
|
||||||
type: BgpType
|
|
||||||
afi: BgpAfi
|
|
||||||
uptime: string | null
|
|
||||||
holdTime: number
|
|
||||||
keepalive: number
|
|
||||||
prefixesRx: number
|
|
||||||
prefixesTx: number
|
|
||||||
prefixesActive: number
|
|
||||||
inputMessages: number
|
|
||||||
outputMessages: number
|
|
||||||
capabilities: string[]
|
|
||||||
lastError: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── AS name lookup ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const AS_NAMES: Record<number, string> = {
|
|
||||||
8359: "МТС / Tele2",
|
|
||||||
13238: "Яндекс",
|
|
||||||
12389: "Ростелеком",
|
|
||||||
24940: "Hetzner",
|
|
||||||
6777: "AMS-IX",
|
|
||||||
1299: "Telia",
|
|
||||||
65001: "iBGP internal",
|
|
||||||
}
|
|
||||||
// ─── mock data ────────────────────────────────────────────────────────────────
|
// ─── mock data ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SESSIONS: BgpSession[] = [
|
const SESSIONS: BgpSession[] = [
|
||||||
@@ -240,13 +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) {
|
function fmtNum(n: number) {
|
||||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||||
@@ -254,108 +217,6 @@ function fmtNum(n: number) {
|
|||||||
return String(n)
|
return String(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PrefixBar({ rx, tx, active }: { rx: number; tx: number; active: number }) {
|
|
||||||
const max = Math.max(rx, 1)
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5 text-[10px] font-mono">
|
|
||||||
{[
|
|
||||||
{ label: "Получено", val: rx, color: "bg-[var(--chart-rx)]", w: rx / max },
|
|
||||||
{ label: "Активных", val: active, color: "bg-[var(--chart-1)]", w: active / max },
|
|
||||||
{ label: "Отправлено", val: tx, color: "bg-[var(--chart-tx)]", w: Math.min(tx / max, 1) },
|
|
||||||
].map(r => (
|
|
||||||
<div key={r.label} className="flex items-center gap-2">
|
|
||||||
<span className="w-20 text-muted-foreground shrink-0">{r.label}</span>
|
|
||||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
|
||||||
<div className={cn("h-full rounded-full", r.color)}
|
|
||||||
style={{ width: `${Math.max(r.w * 100, r.val > 0 ? 2 : 0)}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="w-14 text-right tabular-nums">{fmtNum(r.val)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── RSC snippet ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function rscSnippet(s: BgpSession) {
|
|
||||||
return `/routing bgp connection\nadd name=peer-as${s.remoteAs} remote.address=${s.peerIp}/32 \\\n remote.as=${s.remoteAs} local.role=${s.type === "eBGP" ? "ebgp" : "ibgp"} \\\n output.filter-chain=export-filter input.filter=import-filter \\\n routing-table=main`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── session expanded row ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function SessionDetail({ s }: { s: BgpSession }) {
|
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
function copy() {
|
|
||||||
navigator.clipboard.writeText(rscSnippet(s)).then(() => {
|
|
||||||
setCopied(true); setTimeout(() => setCopied(false), 1800)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="px-4 pb-4 pt-2 bg-muted/20 border-t border-border/60">
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
|
||||||
{[
|
|
||||||
{ label: "Router ID", value: s.routerId },
|
|
||||||
{ label: "Hold / KA", value: `${s.holdTime}s / ${s.keepalive}s` },
|
|
||||||
{ label: "AFI/SAFI", value: s.afi },
|
|
||||||
{ label: "Сообщения ↓/↑", value: `${fmtNum(s.inputMessages)} / ${fmtNum(s.outputMessages)}` },
|
|
||||||
].map(({ label, value }) => (
|
|
||||||
<div key={label}>
|
|
||||||
<p className="text-[10px] text-muted-foreground mb-0.5">{label}</p>
|
|
||||||
<p className="text-xs font-mono font-medium">{value}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* prefix bars */}
|
|
||||||
{s.state === "Established" && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-[10px] text-muted-foreground mb-2 uppercase tracking-wider font-semibold">Префиксы</p>
|
|
||||||
<PrefixBar rx={s.prefixesRx} tx={s.prefixesTx} active={s.prefixesActive} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* capabilities */}
|
|
||||||
{s.capabilities.length > 0 && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">Capabilities</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{s.capabilities.map(c => <CapChip key={c} cap={c} />)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* last error */}
|
|
||||||
{s.lastError && (
|
|
||||||
<div className="mb-4 flex items-center gap-2 rounded-md border border-red-500/20 bg-red-500/5 px-3 py-2">
|
|
||||||
<span className="size-1.5 rounded-full bg-red-500 shrink-0" />
|
|
||||||
<p className="text-xs font-mono text-red-500">{s.lastError}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* rsc export */}
|
|
||||||
<div className="mt-2">
|
|
||||||
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">RouterOS Export</p>
|
|
||||||
<div className="rounded-md bg-[#0a0f1a] border border-white/8 px-3 py-2.5 flex items-start justify-between gap-3">
|
|
||||||
<pre className="text-[10px] font-mono text-[#94a3b8] leading-relaxed whitespace-pre-wrap flex-1 min-w-0">
|
|
||||||
{rscSnippet(s)}
|
|
||||||
</pre>
|
|
||||||
<button onClick={copy}
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 flex items-center gap-1 text-[10px] px-2 py-1 rounded border transition-colors",
|
|
||||||
copied
|
|
||||||
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-400"
|
|
||||||
: "border-white/10 text-white/40 hover:text-white/70 hover:border-white/20",
|
|
||||||
)}>
|
|
||||||
<ClipboardCopyIcon className="size-3" />
|
|
||||||
{copied ? "Скопировано" : "Копировать"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── backend mapping ──────────────────────────────────────────────────────────
|
// ─── backend mapping ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface BackendBgpSession {
|
interface BackendBgpSession {
|
||||||
@@ -397,158 +258,81 @@ function backendToFrontend(b: BackendBgpSession): BgpSession {
|
|||||||
|
|
||||||
// ─── sessions tab ─────────────────────────────────────────────────────────────
|
// ─── sessions tab ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const STATE_FILTERS: Array<{ value: StateFilter; label: string }> = [
|
const BGP_FILTER_ACCESSORS = {
|
||||||
{ value: "all", label: "Все" },
|
state: (s: BgpSession) => s.state,
|
||||||
{ value: "Established", label: "Established" },
|
type: (s: BgpSession) => s.type,
|
||||||
{ value: "Active", label: "Active" },
|
afi: (s: BgpSession) => s.afi,
|
||||||
{ value: "Idle", label: "Idle" },
|
}
|
||||||
{ value: "OpenSent", label: "OpenSent" },
|
|
||||||
]
|
|
||||||
|
|
||||||
function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [stateFilter, setStateFilter] = useState<StateFilter>("all")
|
const [stateFilter, setStateFilter] = useState<StateFilter>("all")
|
||||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
|
||||||
|
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
const filtered = useMemo(() => sessions.filter(s => {
|
const filtered = useMemo(() => {
|
||||||
if (stateFilter !== "all" && s.state !== stateFilter) return false
|
const base = sessions.filter((s) => {
|
||||||
if (typeFilter !== "all" && s.type !== typeFilter) return false
|
if (stateFilter !== "all" && s.state !== stateFilter) return false
|
||||||
if (q && !s.peerIp.includes(q) && !s.description.toLowerCase().includes(q)
|
if (typeFilter !== "all" && s.type !== typeFilter) return false
|
||||||
&& !s.serverLabel.includes(q) && !String(s.remoteAs).includes(q)
|
if (
|
||||||
&& !(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)) return false
|
q &&
|
||||||
return true
|
!s.peerIp.includes(q) &&
|
||||||
}), [sessions, q, stateFilter, typeFilter])
|
!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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<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">
|
<Card className="overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<DataPageToolbar
|
||||||
<table className="w-full text-xs">
|
segmented={{
|
||||||
<thead>
|
value: stateFilter,
|
||||||
<tr className="border-b bg-muted/40">
|
onChange: setStateFilter,
|
||||||
<th className="w-8" />
|
options: [
|
||||||
{["Роутер", "Peer IP", "Remote AS", "Описание", "Тип", "Состояние", "Uptime", "Prefixes ↓", "Prefixes ↑"].map(h => (
|
{ value: "all", label: "Все", count: sessions.length },
|
||||||
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
|
{ value: "Established", label: "Established", count: sessions.filter((s) => s.state === "Established").length },
|
||||||
))}
|
{ value: "Active", label: "Active", count: sessions.filter((s) => s.state === "Active").length },
|
||||||
</tr>
|
{ value: "Idle", label: "Idle", count: sessions.filter((s) => s.state === "Idle").length },
|
||||||
</thead>
|
{ value: "OpenSent", label: "OpenSent", count: sessions.filter((s) => s.state === "OpenSent").length },
|
||||||
<tbody className="divide-y divide-border/60">
|
],
|
||||||
{filtered.map(s => {
|
}}
|
||||||
const isOpen = expandedId === s.id
|
filters={advancedFilters}
|
||||||
return (
|
onFiltersChange={setAdvancedFilters}
|
||||||
<Fragment key={s.id}>
|
filterFields={BGP_FILTER_FIELDS}
|
||||||
<tr
|
search={search}
|
||||||
onClick={() => setExpandedId(isOpen ? null : s.id)}
|
onSearchChange={setSearch}
|
||||||
className={cn(
|
searchPlaceholder="IP, AS, описание…"
|
||||||
"cursor-pointer transition-colors",
|
countLabel={`${filtered.length} из ${sessions.length}`}
|
||||||
isOpen ? "bg-muted/30" : "hover:bg-muted/20",
|
actions={
|
||||||
)}>
|
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
|
||||||
<td className="pl-3 py-2.5">
|
{(["all", "eBGP", "iBGP"] as const).map((t) => (
|
||||||
{isOpen
|
<button
|
||||||
? <ChevronDownIcon className="size-3.5 text-muted-foreground" />
|
key={t}
|
||||||
: <ChevronRightIcon className="size-3.5 text-muted-foreground" />}
|
type="button"
|
||||||
</td>
|
onClick={() => setTypeFilter(t)}
|
||||||
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{s.serverLabel}</td>
|
className={cn(
|
||||||
<td className="px-3 py-2.5 font-mono">{s.peerIp}</td>
|
"px-2.5 py-1 text-[11px] rounded transition-colors",
|
||||||
<td className="px-3 py-2.5 font-mono">
|
typeFilter === t
|
||||||
<div className="flex items-center gap-1.5">
|
? "bg-background text-foreground shadow-sm"
|
||||||
<span>AS{s.remoteAs}</span>
|
: "text-muted-foreground hover:text-foreground",
|
||||||
{AS_NAMES[s.remoteAs] && (
|
)}
|
||||||
<span className="text-muted-foreground text-[10px]">{AS_NAMES[s.remoteAs]}</span>
|
>
|
||||||
)}
|
{t === "all" ? "Все типы" : t}
|
||||||
</div>
|
</button>
|
||||||
</td>
|
))}
|
||||||
<td className="px-3 py-2.5 text-muted-foreground max-w-[180px] truncate">{s.description}</td>
|
</div>
|
||||||
<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">
|
<BgpSessionsDataGrid sessions={filtered} />
|
||||||
{s.uptime ?? "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 font-mono tabular-nums text-right">
|
|
||||||
{s.prefixesRx > 0
|
|
||||||
? <span className="text-emerald-600 dark:text-emerald-400">{fmtNum(s.prefixesRx)}</span>
|
|
||||||
: <span className="text-muted-foreground">—</span>}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 font-mono tabular-nums text-right">
|
|
||||||
{s.prefixesTx > 0
|
|
||||||
? <span className="text-[var(--chart-tx)]">{fmtNum(s.prefixesTx)}</span>
|
|
||||||
: <span className="text-muted-foreground">—</span>}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{isOpen && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={10} className="p-0">
|
|
||||||
<SessionDetail s={s} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={10} className="px-4 py-8 text-center text-sm text-muted-foreground">
|
|
||||||
Нет сессий по заданным фильтрам
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
||||||
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import { routerCertificates, servers as mockServers } from "@/lib/data"
|
import { routerCertificates, servers as mockServers } from "@/lib/data"
|
||||||
import type { CertStatus, Server } from "@/lib/data"
|
import type { CertStatus, Server } from "@/lib/data"
|
||||||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
||||||
@@ -47,7 +49,19 @@ import {
|
|||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
|
UploadIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import {
|
||||||
|
Stepper,
|
||||||
|
StepperContent,
|
||||||
|
StepperIndicator,
|
||||||
|
StepperItem,
|
||||||
|
StepperNav,
|
||||||
|
StepperPanel,
|
||||||
|
StepperSeparator,
|
||||||
|
StepperTitle,
|
||||||
|
StepperTrigger,
|
||||||
|
} from "@/components/reui/stepper"
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<
|
const STATUS_CONFIG: Record<
|
||||||
CertStatus,
|
CertStatus,
|
||||||
@@ -93,60 +107,6 @@ function daysLeftBar(days: number, total = 365): number {
|
|||||||
return Math.min(100, Math.round((days / total) * 100))
|
return Math.min(100, Math.round((days / total) * 100))
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({
|
|
||||||
label,
|
|
||||||
hint,
|
|
||||||
required,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
hint?: string
|
|
||||||
required?: boolean
|
|
||||||
children: ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-sm font-medium">
|
|
||||||
{label}
|
|
||||||
{required && <span className="text-destructive ml-0.5">*</span>}
|
|
||||||
</label>
|
|
||||||
{children}
|
|
||||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={checked}
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
|
|
||||||
checked ? "bg-primary" : "bg-input",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
|
||||||
checked ? "translate-x-4" : "translate-x-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionTitle({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 py-0.5">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
|
|
||||||
<div className="flex-1 h-px bg-border" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
|
function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
|
||||||
return {
|
return {
|
||||||
id: cert.id,
|
id: cert.id,
|
||||||
@@ -679,6 +639,7 @@ function CertPartIssueForm({
|
|||||||
setIssueTrustWww,
|
setIssueTrustWww,
|
||||||
issueTrustApi,
|
issueTrustApi,
|
||||||
setIssueTrustApi,
|
setIssueTrustApi,
|
||||||
|
step,
|
||||||
}: {
|
}: {
|
||||||
serverList: Server[]
|
serverList: Server[]
|
||||||
issueServerId: string
|
issueServerId: string
|
||||||
@@ -693,12 +654,15 @@ function CertPartIssueForm({
|
|||||||
setIssueTrustWww: (v: boolean) => void
|
setIssueTrustWww: (v: boolean) => void
|
||||||
issueTrustApi: boolean
|
issueTrustApi: boolean
|
||||||
setIssueTrustApi: (v: boolean) => void
|
setIssueTrustApi: (v: boolean) => void
|
||||||
|
step?: 1 | 2 | 3 | 4
|
||||||
}) {
|
}) {
|
||||||
|
const showAll = step == null
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
{(showAll || step === 1) && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Основные</SectionTitle>
|
<SectionTitle>Основные</SectionTitle>
|
||||||
<Field label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
|
<FormField label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
|
||||||
<select
|
<select
|
||||||
value={issueServerId}
|
value={issueServerId}
|
||||||
onChange={(e) => setIssueServerId(e.target.value)}
|
onChange={(e) => setIssueServerId(e.target.value)}
|
||||||
@@ -713,41 +677,45 @@ function CertPartIssueForm({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
|
<FormField label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
|
||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
value={issueCertName}
|
value={issueCertName}
|
||||||
onChange={(e) => setIssueCertName(e.target.value)}
|
onChange={(e) => setIssueCertName(e.target.value)}
|
||||||
placeholder="router-le"
|
placeholder="router-le"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(showAll || step === 2) && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Домены</SectionTitle>
|
<SectionTitle>Домены</SectionTitle>
|
||||||
<Field label="Common Name" required hint="Основное имя в сертификате">
|
<FormField label="Common Name" required hint="Основное имя в сертификате">
|
||||||
<Input
|
<Input
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
value={issueCommonName}
|
value={issueCommonName}
|
||||||
onChange={(e) => setIssueCommonName(e.target.value)}
|
onChange={(e) => setIssueCommonName(e.target.value)}
|
||||||
placeholder="vpn.example.com"
|
placeholder="vpn.example.com"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="SAN" hint="По одному имени в строке">
|
<FormField label="SAN" hint="По одному имени в строке">
|
||||||
<textarea
|
<textarea
|
||||||
className="min-h-24 w-full rounded-lg border border-input bg-background px-2.5 py-2 text-sm font-mono text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
className="min-h-24 w-full rounded-lg border border-input bg-background px-2.5 py-2 text-sm font-mono text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
value={issueSans}
|
value={issueSans}
|
||||||
onChange={(e) => setIssueSans(e.target.value)}
|
onChange={(e) => setIssueSans(e.target.value)}
|
||||||
placeholder="www.example.com"
|
placeholder="www.example.com"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
|
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
|
||||||
<p className="font-medium text-foreground mb-1">Let's Encrypt · DNS-01 (Cloudflare)</p>
|
<p className="font-medium text-foreground mb-1">Let's Encrypt · DNS-01 (Cloudflare)</p>
|
||||||
<p>TXT-запись создаётся в Cloudflare, сертификат импортируется на выбранный RouterOS.</p>
|
<p>TXT-запись создаётся в Cloudflare, сертификат импортируется на выбранный RouterOS.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(showAll || step === 3) && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Импорт на RouterOS</SectionTitle>
|
<SectionTitle>Импорт на RouterOS</SectionTitle>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -755,16 +723,29 @@ function CertPartIssueForm({
|
|||||||
<p className="text-sm font-medium">Trust store · www</p>
|
<p className="text-sm font-medium">Trust store · www</p>
|
||||||
<p className="text-xs text-muted-foreground">Веб-интерфейс и HTTPS-сервисы</p>
|
<p className="text-xs text-muted-foreground">Веб-интерфейс и HTTPS-сервисы</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={issueTrustWww} onChange={setIssueTrustWww} />
|
<FormToggle checked={issueTrustWww} onChange={setIssueTrustWww} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Trust store · api</p>
|
<p className="text-sm font-medium">Trust store · api</p>
|
||||||
<p className="text-xs text-muted-foreground">REST API и управление</p>
|
<p className="text-xs text-muted-foreground">REST API и управление</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={issueTrustApi} onChange={setIssueTrustApi} />
|
<FormToggle checked={issueTrustApi} onChange={setIssueTrustApi} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(showAll || step === 4) && (
|
||||||
|
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||||
|
<p className="font-medium mb-2">Проверьте параметры</p>
|
||||||
|
<ul className="text-xs text-muted-foreground space-y-1">
|
||||||
|
<li>Сервер: {serverList.find((s) => s.id === issueServerId)?.name ?? "—"}</li>
|
||||||
|
<li>Имя: {issueCertName || "—"}</li>
|
||||||
|
<li>CN: {issueCommonName || "—"}</li>
|
||||||
|
<li>Trust www: {issueTrustWww ? "да" : "нет"} · api: {issueTrustApi ? "да" : "нет"}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -783,6 +764,8 @@ export default function CertificatesPage() {
|
|||||||
const [serverList, setServerList] = useState<Server[]>([])
|
const [serverList, setServerList] = useState<Server[]>([])
|
||||||
|
|
||||||
const [issueOpen, setIssueOpen] = useState(false)
|
const [issueOpen, setIssueOpen] = useState(false)
|
||||||
|
const [issueStep, setIssueStep] = useState(1)
|
||||||
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
const [issueBusy, setIssueBusy] = useState(false)
|
const [issueBusy, setIssueBusy] = useState(false)
|
||||||
const [issueServerId, setIssueServerId] = useState("")
|
const [issueServerId, setIssueServerId] = useState("")
|
||||||
const [issueCertName, setIssueCertName] = useState("")
|
const [issueCertName, setIssueCertName] = useState("")
|
||||||
@@ -1004,7 +987,11 @@ export default function CertificatesPage() {
|
|||||||
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
|
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => setIssueOpen(true)}>
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
|
<UploadIcon className="size-4" />
|
||||||
|
Импорт
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => { setIssueStep(1); setIssueOpen(true) }}>
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
Выпустить сертификат
|
Выпустить сертификат
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1090,7 +1077,7 @@ export default function CertificatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Sheet open={issueOpen} onOpenChange={setIssueOpen}>
|
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
|
||||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
||||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
<SheetTitle>Выпуск сертификата</SheetTitle>
|
<SheetTitle>Выпуск сертификата</SheetTitle>
|
||||||
@@ -1099,40 +1086,87 @@ export default function CertificatesPage() {
|
|||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
<Stepper value={issueStep} onValueChange={setIssueStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||||
<CertPartIssueForm
|
<StepperNav className="mb-5">
|
||||||
serverList={serverList}
|
{[
|
||||||
issueServerId={issueServerId}
|
{ step: 1, title: "Основные" },
|
||||||
setIssueServerId={setIssueServerId}
|
{ step: 2, title: "Домены" },
|
||||||
issueCertName={issueCertName}
|
{ step: 3, title: "Импорт" },
|
||||||
setIssueCertName={setIssueCertName}
|
{ step: 4, title: "Проверка" },
|
||||||
issueCommonName={issueCommonName}
|
].map(({ step, title }, i, arr) => (
|
||||||
setIssueCommonName={setIssueCommonName}
|
<StepperItem key={step} step={step}>
|
||||||
issueSans={issueSans}
|
<StepperTrigger>
|
||||||
setIssueSans={setIssueSans}
|
<StepperIndicator>{step}</StepperIndicator>
|
||||||
issueTrustWww={issueTrustWww}
|
<StepperTitle className="sr-only">{title}</StepperTitle>
|
||||||
setIssueTrustWww={setIssueTrustWww}
|
</StepperTrigger>
|
||||||
issueTrustApi={issueTrustApi}
|
{i < arr.length - 1 && <StepperSeparator />}
|
||||||
setIssueTrustApi={setIssueTrustApi}
|
</StepperItem>
|
||||||
/>
|
))}
|
||||||
</div>
|
</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">
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" disabled={issueBusy} />}>
|
<SheetClose render={<Button variant="outline" className="flex-1" disabled={issueBusy} />}>
|
||||||
Отмена
|
Отмена
|
||||||
</SheetClose>
|
</SheetClose>
|
||||||
<Button
|
{issueStep > 1 && (
|
||||||
className="flex-1"
|
<Button variant="outline" className="flex-1" disabled={issueBusy} onClick={() => setIssueStep((s) => s - 1)}>
|
||||||
disabled={!liveReady || issueBusy}
|
Назад
|
||||||
onClick={() => {
|
</Button>
|
||||||
void handleIssue()
|
)}
|
||||||
}}
|
{issueStep < 4 ? (
|
||||||
>
|
<Button
|
||||||
{issueBusy ? "Выпуск…" : "Выпустить"}
|
className="flex-1"
|
||||||
</Button>
|
disabled={issueStep === 1 && (!issueServerId || !issueCertName)}
|
||||||
|
onClick={() => setIssueStep((s) => s + 1)}
|
||||||
|
>
|
||||||
|
Далее
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!liveReady || issueBusy}
|
||||||
|
onClick={() => { void handleIssue() }}
|
||||||
|
>
|
||||||
|
{issueBusy ? "Выпуск…" : "Выпустить"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
|
<FileImportDialog
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
title="Импорт сертификата"
|
||||||
|
description="Загрузите PEM, CRT или PKCS#12 для импорта на RouterOS"
|
||||||
|
accept=".pem,.crt,.cer,.p12,.pfx"
|
||||||
|
onImport={async (files) => {
|
||||||
|
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 srvList = liveServersResolved ?? []
|
||||||
const totalSrv = srvList.length
|
const totalSrv = srvList.length
|
||||||
const onlineSrv = srvList.filter((s) => s.status === "online").length
|
const onlineSrv = srvList.filter((s) => s.status === "online").length
|
||||||
|
|||||||
+195
-100
@@ -3,6 +3,7 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
@@ -79,28 +80,6 @@ function collectSettledErrors(results: PromiseSettledResult<unknown>[], labels:
|
|||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
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 fmtMs(ms: number): string {
|
function fmtMs(ms: number): string {
|
||||||
if (ms < 1000) return `${ms} мс`
|
if (ms < 1000) return `${ms} мс`
|
||||||
const s = ms / 1000
|
const s = ms / 1000
|
||||||
@@ -750,6 +729,38 @@ 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() {
|
export default function DataCollectionPage() {
|
||||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
const isLive = prefsHydrated && mode === "live"
|
const isLive = prefsHydrated && mode === "live"
|
||||||
@@ -849,6 +860,17 @@ export default function DataCollectionPage() {
|
|||||||
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled))
|
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled))
|
||||||
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled))
|
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled))
|
||||||
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? 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)
|
const internetPath = readSettled(internetPathRes)
|
||||||
@@ -876,6 +898,138 @@ export default function DataCollectionPage() {
|
|||||||
}
|
}
|
||||||
}, [apiFetch, isLive, runFilterJobKey])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
setTrafficCollector(null)
|
setTrafficCollector(null)
|
||||||
@@ -904,6 +1058,9 @@ export default function DataCollectionPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const enabledJobsCount = useMemo(() => {
|
const enabledJobsCount = useMemo(() => {
|
||||||
|
const jobs = uptimeCollector?.scheduler?.jobs
|
||||||
|
if (jobs?.length) return jobs.filter((job) => job.enabled).length
|
||||||
|
|
||||||
let n = draftTrafficEnabled ? 1 : 0
|
let n = draftTrafficEnabled ? 1 : 0
|
||||||
if (draftServersApiEnabled) n += 1
|
if (draftServersApiEnabled) n += 1
|
||||||
if (draftResourcesEnabled) n += 1
|
if (draftResourcesEnabled) n += 1
|
||||||
@@ -920,6 +1077,7 @@ export default function DataCollectionPage() {
|
|||||||
draftServersApiEnabled,
|
draftServersApiEnabled,
|
||||||
draftSpeedEnabled,
|
draftSpeedEnabled,
|
||||||
draftTrafficEnabled,
|
draftTrafficEnabled,
|
||||||
|
uptimeCollector?.scheduler?.jobs,
|
||||||
])
|
])
|
||||||
|
|
||||||
const schedulerJobCount = SCHEDULER_JOB_KEYS.length
|
const schedulerJobCount = SCHEDULER_JOB_KEYS.length
|
||||||
@@ -936,7 +1094,9 @@ export default function DataCollectionPage() {
|
|||||||
{
|
{
|
||||||
label: "Включено задач",
|
label: "Включено задач",
|
||||||
value: `${enabledJobsCount} / ${schedulerJobCount}`,
|
value: `${enabledJobsCount} / ${schedulerJobCount}`,
|
||||||
sub: "По переключателям на этой странице (до сохранения)",
|
sub: uptimeCollector?.scheduler?.jobs?.length
|
||||||
|
? "По сохранённым задачам планировщика"
|
||||||
|
: "По переключателям на этой странице",
|
||||||
icon: <CalendarClockIcon className="size-4 text-muted-foreground" />,
|
icon: <CalendarClockIcon className="size-4 text-muted-foreground" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1059,7 +1219,7 @@ export default function DataCollectionPage() {
|
|||||||
<CardHeader className="border-b border-border pb-4">
|
<CardHeader className="border-b border-border pb-4">
|
||||||
<CardTitle className="text-base">Планировщик сбора данных</CardTitle>
|
<CardTitle className="text-base">Планировщик сбора данных</CardTitle>
|
||||||
<CardDescription className="text-xs">
|
<CardDescription className="text-xs">
|
||||||
Интервалы и вкл/выкл по задачам. Сохранение отправляет настройки на бекенд и перезапускает таймеры.
|
Интервалы и вкл/выкл по задачам. Переключатель сразу сохраняет задачу на бекенде; кнопка ниже — интервалы и срок хранения.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-0 pb-0">
|
<CardContent className="px-0 pb-0">
|
||||||
@@ -1085,15 +1245,15 @@ export default function DataCollectionPage() {
|
|||||||
? draftTrafficEnabled
|
? draftTrafficEnabled
|
||||||
: jobKey === "servers_rest_ping"
|
: jobKey === "servers_rest_ping"
|
||||||
? draftServersApiEnabled
|
? draftServersApiEnabled
|
||||||
: jobKey === "uptime_resources"
|
: jobKey === "uptime_resources"
|
||||||
? draftResourcesEnabled
|
? draftResourcesEnabled
|
||||||
: jobKey === "uptime_ping"
|
: jobKey === "uptime_ping"
|
||||||
? draftPingEnabled
|
? draftPingEnabled
|
||||||
: jobKey === "uptime_speed"
|
: jobKey === "uptime_speed"
|
||||||
? draftSpeedEnabled
|
? draftSpeedEnabled
|
||||||
: jobKey === "certificates_renew"
|
: jobKey === "certificates_renew"
|
||||||
? draftCertRenewEnabled
|
? draftCertRenewEnabled
|
||||||
: draftInternetPathEnabled
|
: draftInternetPathEnabled
|
||||||
const iv = fixedSchedule
|
const iv = fixedSchedule
|
||||||
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
||||||
: jobKey === "traffic"
|
: jobKey === "traffic"
|
||||||
@@ -1150,17 +1310,12 @@ export default function DataCollectionPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 text-center align-top">
|
<td className="px-3 py-3 text-center align-top">
|
||||||
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
|
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
|
||||||
<Toggle
|
<FormToggle
|
||||||
checked={en}
|
checked={en}
|
||||||
|
disabled={fixedSchedule || schedulerSaveBusy}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
if (fixedSchedule) return
|
if (fixedSchedule || schedulerSaveBusy) return
|
||||||
if (jobKey === "traffic") setDraftTrafficEnabled(v)
|
void handleJobEnabledChange(jobKey, 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)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
@@ -1282,68 +1437,8 @@ export default function DataCollectionPage() {
|
|||||||
setSchedulerSaveBusy(true)
|
setSchedulerSaveBusy(true)
|
||||||
setCollectorError(null)
|
setCollectorError(null)
|
||||||
try {
|
try {
|
||||||
const tInt = Math.max(5, Number.parseInt(trafficIntervalDraft, 10) || 30)
|
await persistSchedulerDrafts()
|
||||||
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: draftTrafficEnabled,
|
|
||||||
intervalSec: tInt,
|
|
||||||
retentionDays: tRet,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
apiFetch("/api/servers-api-ping/settings", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({
|
|
||||||
enabled: draftServersApiEnabled,
|
|
||||||
intervalSec: sApiInt,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
apiFetch("/api/uptime/settings", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({
|
|
||||||
resourcesEnabled: draftResourcesEnabled,
|
|
||||||
pingEnabled: draftPingEnabled,
|
|
||||||
speedEnabled: draftSpeedEnabled,
|
|
||||||
intervalSec: uRes,
|
|
||||||
probeIntervalSec: uPing,
|
|
||||||
speedIntervalSec: uSpd,
|
|
||||||
retentionDays: uRet,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
apiFetch("/api/internet-path/settings", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({
|
|
||||||
enabled: draftInternetPathEnabled,
|
|
||||||
intervalSec: ipInt,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
apiFetch("/api/certificates/renew-settings", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({
|
|
||||||
enabled: draftCertRenewEnabled,
|
|
||||||
intervalSec: certRenewInt,
|
|
||||||
renewBeforeDays,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
const saveErrors = collectSettledErrors(
|
|
||||||
saveResults,
|
|
||||||
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты"],
|
|
||||||
)
|
|
||||||
await loadCollectors()
|
await loadCollectors()
|
||||||
if (saveErrors.length > 0) {
|
|
||||||
setCollectorError(`Не все настройки сохранились: ${saveErrors.join("; ")}`)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
|
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { DataTable } from "@/components/data-table"
|
import { DataTable } from "@/components/data-table"
|
||||||
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import { domains as mockDomains } from "@/lib/data"
|
import { domains as mockDomains } from "@/lib/data"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function DomainsPage() {
|
export default function DomainsPage() {
|
||||||
const { mode } = useDataSource()
|
const { mode } = useDataSource()
|
||||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||||
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
|
|
||||||
const useEvoCatalog = mode === "live" && enabled
|
const useEvoCatalog = mode === "live" && enabled
|
||||||
|
|
||||||
@@ -28,7 +31,9 @@ export default function DomainsPage() {
|
|||||||
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
|
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
|
<UploadIcon className="size-4" />Импорт
|
||||||
|
</Button>
|
||||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||||
<Button size="sm"><PlusIcon className="size-4" />Добавить домен</Button>
|
<Button size="sm"><PlusIcon className="size-4" />Добавить домен</Button>
|
||||||
</>
|
</>
|
||||||
@@ -57,6 +62,7 @@ export default function DomainsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
data={rows}
|
data={rows}
|
||||||
|
isLoading={useEvoCatalog && loading && !snapshot}
|
||||||
searchPlaceholder="Поиск по домену…"
|
searchPlaceholder="Поиск по домену…"
|
||||||
searchKeys={["domain", "asn", "filter"]}
|
searchKeys={["domain", "asn", "filter"]}
|
||||||
columns={[
|
columns={[
|
||||||
@@ -109,6 +115,16 @@ export default function DomainsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FileImportDialog
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
title="Импорт доменов"
|
||||||
|
description="Загрузите CSV или JSON со списком доменов"
|
||||||
|
accept=".csv,.json,text/csv,application/json"
|
||||||
|
onImport={async (files) => {
|
||||||
|
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-16
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState, useCallback, useEffect } from "react"
|
import { useMemo, useState, useCallback, useEffect } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
import { StatusDot } from "@/components/status-dot"
|
import { StatusDot } from "@/components/status-dot"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
@@ -2062,22 +2063,23 @@ export default function FiltersPage() {
|
|||||||
|
|
||||||
{currentRules.length === 0 ? (
|
{currentRules.length === 0 ? (
|
||||||
/* empty state */
|
/* empty state */
|
||||||
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
|
<EmptyState
|
||||||
<NetworkIcon className="size-8 text-muted-foreground/20" />
|
icon={<NetworkIcon className="size-4" />}
|
||||||
<div>
|
title="Нет правил фильтрации"
|
||||||
<p className="text-sm font-medium text-muted-foreground">Нет правил фильтрации</p>
|
description={
|
||||||
<p className="text-xs text-muted-foreground/60 mt-1">
|
(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId))
|
||||||
{(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId))
|
? "Добавьте правило: BGP community → GRE-шлюз"
|
||||||
? "Добавьте правило: BGP community → GRE-шлюз"
|
: "Сначала добавьте GRE-туннели для этого сервера"
|
||||||
: "Сначала добавьте GRE-туннели для этого сервера"}
|
}
|
||||||
</p>
|
action={
|
||||||
</div>
|
(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId)) ? (
|
||||||
{(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId)) && (
|
<Button size="sm" onClick={openCreate}>
|
||||||
<Button size="sm" onClick={openCreate}>
|
<PlusIcon className="size-4" />Добавить правило
|
||||||
<PlusIcon className="size-4" />Добавить правило
|
</Button>
|
||||||
</Button>
|
) : undefined
|
||||||
)}
|
}
|
||||||
</div>
|
className="py-20"
|
||||||
|
/>
|
||||||
) : filteredRules.length === 0 ? (
|
) : filteredRules.length === 0 ? (
|
||||||
/* no search results */
|
/* no search results */
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
||||||
import { firewallRules, type FirewallRule } from "@/lib/data"
|
import { firewallRules, type FirewallRule } from "@/lib/data"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -341,22 +342,6 @@ function fmtHits(n: number): string {
|
|||||||
return String(n)
|
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 }) {
|
function ActionBadge({ action }: { action: string }) {
|
||||||
const cls = ACTION_STYLES[action] ?? "bg-muted text-muted-foreground border-border"
|
const cls = ACTION_STYLES[action] ?? "bg-muted text-muted-foreground border-border"
|
||||||
return (
|
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 }: {
|
function NativeSelect({ value, onChange, children, className }: {
|
||||||
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
|
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
|
||||||
}) {
|
}) {
|
||||||
@@ -507,67 +469,67 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Цепочка и действие</SectionTitle>
|
<SectionTitle>Цепочка и действие</SectionTitle>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Цепочка" required>
|
<FormField label="Цепочка" required>
|
||||||
<NativeSelect value={form.chain} onChange={(v) => set("chain", v)}>
|
<NativeSelect value={form.chain} onChange={(v) => set("chain", v)}>
|
||||||
{chainsForGroup.map((c) => <option key={c} value={c}>{c}</option>)}
|
{chainsForGroup.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Действие" required>
|
<FormField label="Действие" required>
|
||||||
<NativeSelect value={form.action} onChange={(v) => set("action", v)}>
|
<NativeSelect value={form.action} onChange={(v) => set("action", v)}>
|
||||||
{actions.map((a) => <option key={a} value={a}>{a}</option>)}
|
{actions.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Matching */}
|
{/* Matching */}
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Условие совпадения</SectionTitle>
|
<SectionTitle>Условие совпадения</SectionTitle>
|
||||||
<Field label="Протокол">
|
<FormField label="Протокол">
|
||||||
<NativeSelect value={form.proto} onChange={(v) => set("proto", v)}>
|
<NativeSelect value={form.proto} onChange={(v) => set("proto", v)}>
|
||||||
{["all","tcp","udp","icmp","gre","esp","ah","ipencap","ospf"].map((p) =>
|
{["all","tcp","udp","icmp","gre","esp","ah","ipencap","ospf"].map((p) =>
|
||||||
<option key={p} value={p}>{p}</option>
|
<option key={p} value={p}>{p}</option>
|
||||||
)}
|
)}
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Src-address / Address-list" hint="IP, CIDR или имя address-list">
|
<FormField label="Src-address / Address-list" hint="IP, CIDR или имя address-list">
|
||||||
<Input className="font-mono h-8" placeholder="10.0.0.0/8"
|
<Input className="font-mono h-8" placeholder="10.0.0.0/8"
|
||||||
value={form.srcAddrList} onChange={(e) => set("srcAddrList", e.target.value)} />
|
value={form.srcAddrList} onChange={(e) => set("srcAddrList", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Dst-address / Address-list">
|
<FormField label="Dst-address / Address-list">
|
||||||
<Input className="font-mono h-8" placeholder="0.0.0.0/0"
|
<Input className="font-mono h-8" placeholder="0.0.0.0/0"
|
||||||
value={form.dstAddrList} onChange={(e) => set("dstAddrList", e.target.value)} />
|
value={form.dstAddrList} onChange={(e) => set("dstAddrList", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Src-port" hint="TCP/UDP, напр. 1024-65535">
|
<FormField label="Src-port" hint="TCP/UDP, напр. 1024-65535">
|
||||||
<Input className="font-mono h-8" placeholder="—"
|
<Input className="font-mono h-8" placeholder="—"
|
||||||
value={form.srcPort} onChange={(e) => set("srcPort", e.target.value)} />
|
value={form.srcPort} onChange={(e) => set("srcPort", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Dst-port">
|
<FormField label="Dst-port">
|
||||||
<Input className="font-mono h-8" placeholder="443"
|
<Input className="font-mono h-8" placeholder="443"
|
||||||
value={form.dstPort} onChange={(e) => set("dstPort", e.target.value)} />
|
value={form.dstPort} onChange={(e) => set("dstPort", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="In-interface" hint="Входящий интерфейс">
|
<FormField label="In-interface" hint="Входящий интерфейс">
|
||||||
<Input className="font-mono h-8" placeholder="wan-msk"
|
<Input className="font-mono h-8" placeholder="wan-msk"
|
||||||
value={form.inIface} onChange={(e) => set("inIface", e.target.value)} />
|
value={form.inIface} onChange={(e) => set("inIface", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Out-interface">
|
<FormField label="Out-interface">
|
||||||
<Input className="font-mono h-8" placeholder="lan"
|
<Input className="font-mono h-8" placeholder="lan"
|
||||||
value={form.outIface} onChange={(e) => set("outIface", e.target.value)} />
|
value={form.outIface} onChange={(e) => set("outIface", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Connection-state" hint="Через запятую: new, established, related, invalid">
|
<FormField label="Connection-state" hint="Через запятую: new, established, related, invalid">
|
||||||
<Input className="font-mono h-8" placeholder="new,established"
|
<Input className="font-mono h-8" placeholder="new,established"
|
||||||
value={form.connState} onChange={(e) => set("connState", e.target.value)} />
|
value={form.connState} onChange={(e) => set("connState", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Log + Comment */}
|
{/* Log + Comment */}
|
||||||
@@ -578,18 +540,18 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
|
|||||||
<p className="text-sm font-medium">Log</p>
|
<p className="text-sm font-medium">Log</p>
|
||||||
<p className="text-xs text-muted-foreground">Записывать совпадения в системный лог</p>
|
<p className="text-xs text-muted-foreground">Записывать совпадения в системный лог</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={form.log} onChange={(v) => set("log", v)} />
|
<FormToggle checked={form.log} onChange={(v) => set("log", v)} />
|
||||||
</div>
|
</div>
|
||||||
{form.log && (
|
{form.log && (
|
||||||
<Field label="Log-prefix" hint="Метка в логе, например FW-DROP">
|
<FormField label="Log-prefix" hint="Метка в логе, например FW-DROP">
|
||||||
<Input className="font-mono h-8" placeholder="FW-RULE"
|
<Input className="font-mono h-8" placeholder="FW-RULE"
|
||||||
value={form.logPrefix} onChange={(e) => set("logPrefix", e.target.value)} />
|
value={form.logPrefix} onChange={(e) => set("logPrefix", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
<Field label="Комментарий">
|
<FormField label="Комментарий">
|
||||||
<Input className="h-8" placeholder="Описание правила"
|
<Input className="h-8" placeholder="Описание правила"
|
||||||
value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Enabled */}
|
{/* Enabled */}
|
||||||
@@ -598,7 +560,7 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
|
|||||||
<p className="text-sm font-medium">Правило включено</p>
|
<p className="text-sm font-medium">Правило включено</p>
|
||||||
<p className="text-xs text-muted-foreground">Отключённые правила сохраняются, но не применяются</p>
|
<p className="text-xs text-muted-foreground">Отключённые правила сохраняются, но не применяются</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={form.enabled} onChange={(v) => set("enabled", v)} />
|
<FormToggle checked={form.enabled} onChange={(v) => set("enabled", v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CLI preview */}
|
{/* CLI preview */}
|
||||||
@@ -1039,14 +1001,14 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<SectionTitle>Название</SectionTitle>
|
<SectionTitle>Название</SectionTitle>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Название сценария" required>
|
<FormField label="Название сценария" required>
|
||||||
<Input className="h-8" placeholder="Блокировка Tor Exit"
|
<Input className="h-8" placeholder="Блокировка Tor Exit"
|
||||||
value={name} onChange={e => setName(e.target.value)} />
|
value={name} onChange={e => setName(e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Описание">
|
<FormField label="Описание">
|
||||||
<Input className="h-8" placeholder="Краткое описание"
|
<Input className="h-8" placeholder="Краткое описание"
|
||||||
value={desc} onChange={e => setDesc(e.target.value)} />
|
value={desc} onChange={e => setDesc(e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1054,7 +1016,7 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<SectionTitle>Тестовый пакет по умолчанию</SectionTitle>
|
<SectionTitle>Тестовый пакет по умолчанию</SectionTitle>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
<Field label="Направление / цепочка">
|
<FormField label="Направление / цепочка">
|
||||||
<NativeSelect value={pkt.chain} onChange={v => setP("chain", v)}>
|
<NativeSelect value={pkt.chain} onChange={v => setP("chain", v)}>
|
||||||
<optgroup label="Полный маршрут">
|
<optgroup label="Полный маршрут">
|
||||||
<option value="forward">forward — транзит</option>
|
<option value="forward">forward — транзит</option>
|
||||||
@@ -1067,40 +1029,40 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
|||||||
.map(c => <option key={c} value={c}>{c}</option>)}
|
.map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Протокол">
|
<FormField label="Протокол">
|
||||||
<NativeSelect value={pkt.proto} onChange={v => setP("proto", v)}>
|
<NativeSelect value={pkt.proto} onChange={v => setP("proto", v)}>
|
||||||
{PROTO_OPTS.map(p => <option key={p} value={p}>{p}</option>)}
|
{PROTO_OPTS.map(p => <option key={p} value={p}>{p}</option>)}
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Conn-state">
|
<FormField label="Conn-state">
|
||||||
<Input className="font-mono h-8" value={pkt.connState}
|
<Input className="font-mono h-8" value={pkt.connState}
|
||||||
placeholder="new" onChange={e => setP("connState", e.target.value)} />
|
placeholder="new" onChange={e => setP("connState", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Src IP">
|
<FormField label="Src IP">
|
||||||
<Input className="font-mono h-8" value={pkt.srcAddr}
|
<Input className="font-mono h-8" value={pkt.srcAddr}
|
||||||
onChange={e => setP("srcAddr", e.target.value)} />
|
onChange={e => setP("srcAddr", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Dst IP">
|
<FormField label="Dst IP">
|
||||||
<Input className="font-mono h-8" value={pkt.dstAddr}
|
<Input className="font-mono h-8" value={pkt.dstAddr}
|
||||||
onChange={e => setP("dstAddr", e.target.value)} />
|
onChange={e => setP("dstAddr", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Dst Port">
|
<FormField label="Dst Port">
|
||||||
<Input className="font-mono h-8" value={pkt.dstPort}
|
<Input className="font-mono h-8" value={pkt.dstPort}
|
||||||
placeholder="443" onChange={e => setP("dstPort", e.target.value)} />
|
placeholder="443" onChange={e => setP("dstPort", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="In-interface">
|
<FormField label="In-interface">
|
||||||
<Input className="font-mono h-8" value={pkt.inIface}
|
<Input className="font-mono h-8" value={pkt.inIface}
|
||||||
placeholder="lan" onChange={e => setP("inIface", e.target.value)} />
|
placeholder="lan" onChange={e => setP("inIface", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Out-interface">
|
<FormField label="Out-interface">
|
||||||
<Input className="font-mono h-8" value={pkt.outIface}
|
<Input className="font-mono h-8" value={pkt.outIface}
|
||||||
placeholder="wan-msk" onChange={e => setP("outIface", e.target.value)} />
|
placeholder="wan-msk" onChange={e => setP("outIface", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Dst addr-list">
|
<FormField label="Dst addr-list">
|
||||||
<Input className="font-mono h-8" value={pkt.dstAddrList}
|
<Input className="font-mono h-8" value={pkt.dstAddrList}
|
||||||
placeholder="" onChange={e => setP("dstAddrList", e.target.value)} />
|
placeholder="" onChange={e => setP("dstAddrList", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1170,7 +1132,7 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Toggle checked={addForm.enabled} onChange={v => setAF("enabled", v)} />
|
<FormToggle checked={addForm.enabled} onChange={v => setAF("enabled", v)} />
|
||||||
<span className="text-xs text-muted-foreground">Включено</span>
|
<span className="text-xs text-muted-foreground">Включено</span>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={addRule}><PlusIcon className="size-4" />Добавить</Button>
|
<Button size="sm" onClick={addRule}><PlusIcon className="size-4" />Добавить</Button>
|
||||||
@@ -1783,7 +1745,7 @@ function RulesTable({ rules, onToggle, onEdit }: {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5">
|
<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>
|
||||||
<td className="px-3 py-2.5">
|
<td className="px-3 py-2.5">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
+47
-98
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
||||||
import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data"
|
import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data"
|
||||||
import type { GrePool, GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion, Server } from "@/lib/data"
|
import type { GrePool, GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion, Server } from "@/lib/data"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
@@ -138,58 +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) ─────────────────────────────────────────────
|
// ─── Live API (как на /filters) ─────────────────────────────────────────────
|
||||||
|
|
||||||
interface BackendServer {
|
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-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Основные</SectionTitle>
|
<SectionTitle>Основные</SectionTitle>
|
||||||
<Field label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
|
<FormField label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
|
||||||
<Input className="font-mono" placeholder="gre-msk-spb" value={tForm.name} onChange={(e) => setT("name", e.target.value)} />
|
<Input className="font-mono" placeholder="gre-msk-spb" value={tForm.name} onChange={(e) => setT("name", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Сервер (MikroTik)" required>
|
<FormField label="Сервер (MikroTik)" required>
|
||||||
<select value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)}
|
<select value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)}
|
||||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||||
<option value="" disabled>Выбрать сервер…</option>
|
<option value="" disabled>Выбрать сервер…</option>
|
||||||
{displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
|
{displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Комментарий">
|
<FormField label="Комментарий">
|
||||||
<Input placeholder="Описание туннеля" value={tForm.comment} onChange={(e) => setT("comment", e.target.value)} />
|
<Input placeholder="Описание туннеля" value={tForm.comment} onChange={(e) => setT("comment", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">Включён</span>
|
<span className="text-sm font-medium">Включён</span>
|
||||||
<Toggle checked={tForm.enabled} onChange={(v) => setT("enabled", v)} />
|
<FormToggle checked={tForm.enabled} onChange={(v) => setT("enabled", v)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Эндпоинты</SectionTitle>
|
<SectionTitle>Эндпоинты</SectionTitle>
|
||||||
<Field label="Локальный адрес" hint="Оставьте пустым или 0.0.0.0 для автоопределения">
|
<FormField label="Локальный адрес" hint="Оставьте пустым или 0.0.0.0 для автоопределения">
|
||||||
<Input className="font-mono" placeholder="0.0.0.0" value={tForm.localAddress} onChange={(e) => setT("localAddress", e.target.value)} />
|
<Input className="font-mono" placeholder="0.0.0.0" value={tForm.localAddress} onChange={(e) => setT("localAddress", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Удалённый адрес" required hint="Внешний IP удалённого MikroTik">
|
<FormField label="Удалённый адрес" required hint="Внешний IP удалённого MikroTik">
|
||||||
<Input className="font-mono" placeholder="203.0.113.1" value={tForm.remoteAddress} onChange={(e) => setT("remoteAddress", e.target.value)} />
|
<Input className="font-mono" placeholder="203.0.113.1" value={tForm.remoteAddress} onChange={(e) => setT("remoteAddress", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Внутренний IP</SectionTitle>
|
<SectionTitle>Внутренний IP</SectionTitle>
|
||||||
<Field label="IP-пул" required hint="Из какого пула выделяется /30-блок">
|
<FormField label="IP-пул" required hint="Из какого пула выделяется /30-блок">
|
||||||
<select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)}
|
<select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)}
|
||||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||||
<option value="" disabled>Выбрать пул…</option>
|
<option value="" disabled>Выбрать пул…</option>
|
||||||
{displayPools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) — свободно {p.total - p.allocated} блоков</option>)}
|
{displayPools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) — свободно {p.total - p.allocated} блоков</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</FormField>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Локальный IP" required hint="/ip address на этом конце">
|
<FormField label="Локальный IP" required hint="/ip address на этом конце">
|
||||||
<Input className="font-mono" placeholder="10.200.0.1/30" value={tForm.localInnerIp} onChange={(e) => setT("localInnerIp", e.target.value)} />
|
<Input className="font-mono" placeholder="10.200.0.1/30" value={tForm.localInnerIp} onChange={(e) => setT("localInnerIp", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Удалённый IP" required hint="/ip address на другом конце">
|
<FormField label="Удалённый IP" required hint="/ip address на другом конце">
|
||||||
<Input className="font-mono" placeholder="10.200.0.2/30" value={tForm.remoteInnerIp} onChange={(e) => setT("remoteInnerIp", e.target.value)} />
|
<Input className="font-mono" placeholder="10.200.0.2/30" value={tForm.remoteInnerIp} onChange={(e) => setT("remoteInnerIp", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -893,12 +842,12 @@ export default function GrePage() {
|
|||||||
<p className="text-sm font-medium">Включить IPsec</p>
|
<p className="text-sm font-medium">Включить IPsec</p>
|
||||||
<p className="text-xs text-muted-foreground">RouterOS автоматически создаст peer, policy и proposal</p>
|
<p className="text-xs text-muted-foreground">RouterOS автоматически создаст peer, policy и proposal</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={tForm.ipsecEnabled} onChange={(v) => setT("ipsecEnabled", v)} />
|
<FormToggle checked={tForm.ipsecEnabled} onChange={(v) => setT("ipsecEnabled", v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tForm.ipsecEnabled && (
|
{tForm.ipsecEnabled && (
|
||||||
<div className="flex flex-col gap-4 pl-4 border-l-2 border-emerald-500/30">
|
<div className="flex flex-col gap-4 pl-4 border-l-2 border-emerald-500/30">
|
||||||
<Field label="Пароль (PSK)" required hint="ipsec-secret — pre-shared key для автоматического IKE">
|
<FormField label="Пароль (PSK)" required hint="ipsec-secret — pre-shared key для автоматического IKE">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input type={tForm.ipsecShowSecret ? "text" : "password"} className="font-mono pr-9"
|
<Input type={tForm.ipsecShowSecret ? "text" : "password"} className="font-mono pr-9"
|
||||||
placeholder="Минимум 8 символов" value={tForm.ipsecSecret} onChange={(e) => setT("ipsecSecret", e.target.value)} />
|
placeholder="Минимум 8 символов" value={tForm.ipsecSecret} onChange={(e) => setT("ipsecSecret", e.target.value)} />
|
||||||
@@ -907,38 +856,38 @@ export default function GrePage() {
|
|||||||
{tForm.ipsecShowSecret ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
{tForm.ipsecShowSecret ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="IKE-версия">
|
<FormField label="IKE-версия">
|
||||||
<SegmentedControl value={tForm.ipsecIkeVersion} onChange={(v) => setT("ipsecIkeVersion", v)}
|
<SegmentedControl value={tForm.ipsecIkeVersion} onChange={(v) => setT("ipsecIkeVersion", v)}
|
||||||
options={[{ value: "ikev1", label: "IKEv1" }, { value: "ikev2", label: "IKEv2 (рек.)" }]} />
|
options={[{ value: "ikev1", label: "IKEv1" }, { value: "ikev2", label: "IKEv2 (рек.)" }]} />
|
||||||
</Field>
|
</FormField>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Шифрование">
|
<FormField label="Шифрование">
|
||||||
<select value={tForm.ipsecEncAlg} onChange={(e) => setT("ipsecEncAlg", e.target.value as IpsecEncAlg)}
|
<select value={tForm.ipsecEncAlg} onChange={(e) => setT("ipsecEncAlg", e.target.value as IpsecEncAlg)}
|
||||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||||
{(Object.entries(ENC_LABELS) as [IpsecEncAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
{(Object.entries(ENC_LABELS) as [IpsecEncAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Хеш-алгоритм">
|
<FormField label="Хеш-алгоритм">
|
||||||
<select value={tForm.ipsecAuthAlg} onChange={(e) => setT("ipsecAuthAlg", e.target.value as IpsecAuthAlg)}
|
<select value={tForm.ipsecAuthAlg} onChange={(e) => setT("ipsecAuthAlg", e.target.value as IpsecAuthAlg)}
|
||||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||||
{(Object.entries(AUTH_LABELS) as [IpsecAuthAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
{(Object.entries(AUTH_LABELS) as [IpsecAuthAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<Field label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
|
<FormField label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
|
||||||
<select value={tForm.ipsecDhGroup} onChange={(e) => setT("ipsecDhGroup", e.target.value as IpsecDhGroup)}
|
<select value={tForm.ipsecDhGroup} onChange={(e) => setT("ipsecDhGroup", e.target.value as IpsecDhGroup)}
|
||||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||||
{(Object.entries(DH_LABELS) as [IpsecDhGroup, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
{(Object.entries(DH_LABELS) as [IpsecDhGroup, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</FormField>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Срок жизни SA" hint="Формат: 1d 00:00:00">
|
<FormField label="Срок жизни SA" hint="Формат: 1d 00:00:00">
|
||||||
<Input className="font-mono" value={tForm.ipsecLifetime} onChange={(e) => setT("ipsecLifetime", e.target.value)} />
|
<Input className="font-mono" value={tForm.ipsecLifetime} onChange={(e) => setT("ipsecLifetime", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<div className="flex items-center justify-between pt-6">
|
<div className="flex items-center justify-between pt-6">
|
||||||
<span className="text-sm font-medium">PFS</span>
|
<span className="text-sm font-medium">PFS</span>
|
||||||
<Toggle checked={tForm.ipsecPfs} onChange={(v) => setT("ipsecPfs", v)} />
|
<FormToggle checked={tForm.ipsecPfs} onChange={(v) => setT("ipsecPfs", v)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -955,23 +904,23 @@ export default function GrePage() {
|
|||||||
{tForm.showAdvanced && (
|
{tForm.showAdvanced && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<Field label="MTU" hint="По умолч. 1476">
|
<FormField label="MTU" hint="По умолч. 1476">
|
||||||
<Input type="number" className="font-mono" value={tForm.mtu} onChange={(e) => setT("mtu", Number(e.target.value))} />
|
<Input type="number" className="font-mono" value={tForm.mtu} onChange={(e) => setT("mtu", Number(e.target.value))} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Keepalive, с" hint="0 = откл.">
|
<FormField label="Keepalive, с" hint="0 = откл.">
|
||||||
<Input type="number" className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
|
<Input type="number" className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Попытки">
|
<FormField label="Попытки">
|
||||||
<Input type="number" className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} />
|
<Input type="number" className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<Field label="DSCP">
|
<FormField label="DSCP">
|
||||||
<select value={tForm.dscp} onChange={(e) => setT("dscp", e.target.value)}
|
<select value={tForm.dscp} onChange={(e) => setT("dscp", e.target.value)}
|
||||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||||
<option value="inherit">inherit</option>
|
<option value="inherit">inherit</option>
|
||||||
{Array.from({ length: 64 }, (_, i) => <option key={i} value={String(i)}>{i}</option>)}
|
{Array.from({ length: 64 }, (_, i) => <option key={i} value={String(i)}>{i}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</FormField>
|
||||||
{[
|
{[
|
||||||
{ key: "clampTcpMss" as const, label: "Clamp TCP MSS", desc: "Ограничить MSS до MTU туннеля" },
|
{ key: "clampTcpMss" as const, label: "Clamp TCP MSS", desc: "Ограничить MSS до MTU туннеля" },
|
||||||
{ key: "allowFastPath" as const, label: "Allow Fast Path", desc: "Аппаратное ускорение трафика" },
|
{ key: "allowFastPath" as const, label: "Allow Fast Path", desc: "Аппаратное ускорение трафика" },
|
||||||
@@ -981,7 +930,7 @@ export default function GrePage() {
|
|||||||
<p className="text-sm font-medium">{label}</p>
|
<p className="text-sm font-medium">{label}</p>
|
||||||
<p className="text-xs text-muted-foreground">{desc}</p>
|
<p className="text-xs text-muted-foreground">{desc}</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={tForm[key] as boolean} onChange={(v) => setT(key, v)} />
|
<FormToggle checked={tForm[key] as boolean} onChange={(v) => setT(key, v)} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1006,18 +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-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Параметры пула</SectionTitle>
|
<SectionTitle>Параметры пула</SectionTitle>
|
||||||
<Field label="Имя пула" required hint="Например pool-gre-office или pool-gre-dc2">
|
<FormField label="Имя пула" required hint="Например pool-gre-office или pool-gre-dc2">
|
||||||
<Input className="font-mono" placeholder="pool-gre-core" value={pForm.name}
|
<Input className="font-mono" placeholder="pool-gre-core" value={pForm.name}
|
||||||
onChange={(e) => setPForm((f) => ({ ...f, name: e.target.value }))} />
|
onChange={(e) => setPForm((f) => ({ ...f, name: e.target.value }))} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Диапазон CIDR" required hint="Блок, из которого будут нарезаться /30 на каждый туннель">
|
<FormField label="Диапазон CIDR" required hint="Блок, из которого будут нарезаться /30 на каждый туннель">
|
||||||
<Input className="font-mono" placeholder="10.200.0.0/24" value={pForm.cidr}
|
<Input className="font-mono" placeholder="10.200.0.0/24" value={pForm.cidr}
|
||||||
onChange={(e) => setPForm((f) => ({ ...f, cidr: e.target.value }))} />
|
onChange={(e) => setPForm((f) => ({ ...f, cidr: e.target.value }))} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Назначение / Комментарий">
|
<FormField label="Назначение / Комментарий">
|
||||||
<Input placeholder="Ядровые межузловые туннели" value={pForm.comment}
|
<Input placeholder="Ядровые межузловые туннели" value={pForm.comment}
|
||||||
onChange={(e) => setPForm((f) => ({ ...f, comment: e.target.value }))} />
|
onChange={(e) => setPForm((f) => ({ ...f, comment: e.target.value }))} />
|
||||||
</Field>
|
</FormField>
|
||||||
{pForm.cidr && /\/\d+$/.test(pForm.cidr) && (() => {
|
{pForm.cidr && /\/\d+$/.test(pForm.cidr) && (() => {
|
||||||
const prefix = parseInt(pForm.cidr.split("/")[1] ?? "0")
|
const prefix = parseInt(pForm.cidr.split("/")[1] ?? "0")
|
||||||
const blocks = prefix <= 30 ? Math.pow(2, 30 - prefix) : 0
|
const blocks = prefix <= 30 ? Math.pow(2, 30 - prefix) : 0
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { DataTable } from "@/components/data-table"
|
import { DataTable } from "@/components/data-table"
|
||||||
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import { ipRanges as mockIpRanges } from "@/lib/data"
|
import { ipRanges as mockIpRanges } from "@/lib/data"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function IpRangesPage() {
|
export default function IpRangesPage() {
|
||||||
const { mode } = useDataSource()
|
const { mode } = useDataSource()
|
||||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||||
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
|
|
||||||
/** При включённом EvoBGP в live локальные моки не показываем — только каталог API (или пусто при загрузке/ошибке). */
|
|
||||||
const useEvoCatalog = mode === "live" && enabled
|
const useEvoCatalog = mode === "live" && enabled
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
@@ -29,7 +31,9 @@ export default function IpRangesPage() {
|
|||||||
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
|
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
|
<UploadIcon className="size-4" />Импорт
|
||||||
|
</Button>
|
||||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||||
<Button size="sm"><PlusIcon className="size-4" />Добавить диапазон</Button>
|
<Button size="sm"><PlusIcon className="size-4" />Добавить диапазон</Button>
|
||||||
</>
|
</>
|
||||||
@@ -58,6 +62,7 @@ export default function IpRangesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
data={rows}
|
data={rows}
|
||||||
|
isLoading={useEvoCatalog && loading && !snapshot}
|
||||||
searchPlaceholder="Поиск по CIDR, ASN…"
|
searchPlaceholder="Поиск по CIDR, ASN…"
|
||||||
searchKeys={["cidr", "asn", "country", "filter"]}
|
searchKeys={["cidr", "asn", "country", "filter"]}
|
||||||
columns={[
|
columns={[
|
||||||
@@ -108,6 +113,16 @@ export default function IpRangesPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FileImportDialog
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
title="Импорт IP-диапазонов"
|
||||||
|
description="Загрузите CSV или JSON со списком CIDR-блоков"
|
||||||
|
accept=".csv,.json,text/csv,application/json"
|
||||||
|
onImport={async (files) => {
|
||||||
|
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function MainLoading() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5 p-6">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-24 rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-10 w-full max-w-md rounded-lg" />
|
||||||
|
<Skeleton className="h-96 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
|
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
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 }) {
|
function OptionLabel({ children }: { children: React.ReactNode }) {
|
||||||
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
|
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
|
||||||
}
|
}
|
||||||
@@ -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",
|
"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",
|
!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))} />
|
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>
|
<code className="font-mono text-xs truncate">{tun?.name ?? rule.tunnelId}</code>
|
||||||
<span className="text-xs text-muted-foreground truncate">{src?.name ?? rule.srcId}</span>
|
<span className="text-xs text-muted-foreground truncate">{src?.name ?? rule.srcId}</span>
|
||||||
@@ -1115,7 +1105,7 @@ export default function ProbesPage() {
|
|||||||
Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.
|
Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={traceUseDns} onChange={setTraceUseDns} />
|
<FormToggle checked={traceUseDns} onChange={setTraceUseDns} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -148,32 +149,6 @@ const emptyForm = (): RouteForm => ({
|
|||||||
endpoints: [newEndpoint()],
|
endpoints: [newEndpoint()],
|
||||||
})
|
})
|
||||||
|
|
||||||
function Field({ label, hint, required, children }: {
|
|
||||||
label: string
|
|
||||||
hint?: string
|
|
||||||
required?: boolean
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-sm font-medium">
|
|
||||||
{label}{required && <span className="text-destructive ml-0.5">*</span>}
|
|
||||||
</label>
|
|
||||||
{children}
|
|
||||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 py-0.5">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
|
|
||||||
<div className="flex-1 h-px bg-border" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RouteGroupRows({
|
function RouteGroupRows({
|
||||||
group, expanded, onToggle, onEdit, onDelete,
|
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-1 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Основные</SectionTitle>
|
<SectionTitle>Основные</SectionTitle>
|
||||||
<Field label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24">
|
<FormField label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24">
|
||||||
<Input className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} />
|
<Input className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -396,26 +371,26 @@ function RouteSheet({
|
|||||||
|
|
||||||
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
|
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
|
||||||
|
|
||||||
<Field label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
|
<FormField label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
|
||||||
<Input className="font-mono h-9" placeholder="1.2.3.4%GW-NAME" value={ep.gateway} onChange={(e) => setEp(ep.id, "gateway", e.target.value)} />
|
<Input className="font-mono h-9" placeholder="1.2.3.4%GW-NAME" value={ep.gateway} onChange={(e) => setEp(ep.id, "gateway", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Field label="Distance (приоритет)">
|
<FormField label="Distance (приоритет)">
|
||||||
<Input type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value) || 1)} />
|
<Input type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value) || 1)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Check Gateway">
|
<FormField label="Check Gateway">
|
||||||
<Input className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
|
<Input className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Field label="Scope">
|
<FormField label="Scope">
|
||||||
<Input type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
|
<Input type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="T.Scope">
|
<FormField label="T.Scope">
|
||||||
<Input type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
|
<Input type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5 max-h-[180px] overflow-y-auto overflow-x-hidden pr-1">
|
<div className="flex flex-col gap-1.5 max-h-[180px] overflow-y-auto overflow-x-hidden pr-1">
|
||||||
@@ -467,10 +442,10 @@ function RouteSheet({
|
|||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Параметры</SectionTitle>
|
<SectionTitle>Параметры</SectionTitle>
|
||||||
<Field label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></Field>
|
<FormField label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></FormField>
|
||||||
<Field label="Комментарий">
|
<FormField label="Комментарий">
|
||||||
<Input className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
<Input className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon className="size-4 shrink-0" />{error}</div>}
|
{error && <div className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon className="size-4 shrink-0" />{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
|
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -245,17 +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 }) {
|
function NInput({ value, onChange, min, max }: { value: number; onChange: (v: number) => void; min?: number; max?: number }) {
|
||||||
return (
|
return (
|
||||||
<Input type="number" value={value} min={min} max={max}
|
<Input type="number" value={value} min={min} max={max}
|
||||||
@@ -1167,7 +1157,7 @@ export default function RouteOptimizerPage() {
|
|||||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Автоприменение</p>
|
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Автоприменение</p>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm">Применять автоматически</span>
|
<span className="text-sm">Применять автоматически</span>
|
||||||
<Toggle checked={settings.autoApply} onChange={v => set("autoApply", v)} />
|
<FormToggle checked={settings.autoApply} onChange={v => set("autoApply", v)} />
|
||||||
</div>
|
</div>
|
||||||
{settings.autoApply && (
|
{settings.autoApply && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
+155
-468
@@ -1,8 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Fragment, useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { StatusBadge } from "@/components/status-badge"
|
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
||||||
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
|
import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"
|
||||||
|
import type { Filter } from "@/components/reui/filters"
|
||||||
|
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
|
||||||
|
import { SERVER_FILTER_ACCESSORS, SERVER_FILTER_FIELDS } from "@/lib/data-filters/server-filter-fields"
|
||||||
import { servers as initialServers } from "@/lib/data"
|
import { servers as initialServers } from "@/lib/data"
|
||||||
import type { ServerType, Server, WanUplink } from "@/lib/data"
|
import type { ServerType, Server, WanUplink } from "@/lib/data"
|
||||||
import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers"
|
import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers"
|
||||||
@@ -30,56 +35,25 @@ import {
|
|||||||
SheetDescription, SheetFooter, SheetClose,
|
SheetDescription, SheetFooter, SheetClose,
|
||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
import {
|
import {
|
||||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
Stepper,
|
||||||
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
|
StepperContent,
|
||||||
} from "@/components/ui/dropdown-menu"
|
StepperIndicator,
|
||||||
|
StepperItem,
|
||||||
|
StepperNav,
|
||||||
|
StepperPanel,
|
||||||
|
StepperSeparator,
|
||||||
|
StepperTitle,
|
||||||
|
StepperTrigger,
|
||||||
|
} from "@/components/reui/stepper"
|
||||||
import {
|
import {
|
||||||
SearchIcon, RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
||||||
MoreHorizontalIcon, EyeIcon, EyeOffIcon,
|
EyeIcon, EyeOffIcon,
|
||||||
ChevronRightIcon, ChevronDownIcon,
|
ChevronRightIcon, ChevronDownIcon,
|
||||||
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
||||||
ShieldIcon, WifiIcon, PencilIcon, PowerIcon, Trash2Icon, ExternalLinkIcon,
|
ShieldIcon, WifiIcon,
|
||||||
HomeIcon, ServerIcon, NetworkIcon,
|
HomeIcon, ServerIcon, NetworkIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
// ─── RouterOS version utilities ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Numeric version score: "7.20.1 (stable)" → 720, "7.14.2" → 714, "7.9" → 709 */
|
|
||||||
function rosVer(os: string): number {
|
|
||||||
const m = os.match(/(\d+)\.(\d+)/)
|
|
||||||
if (!m) return 0
|
|
||||||
return parseInt(m[1], 10) * 100 + parseInt(m[2], 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RosFeature { name: string; minVer: number; label: string; desc: string }
|
|
||||||
|
|
||||||
const ROS_FEATURES: RosFeature[] = [
|
|
||||||
{ name: "WireGuard", minVer: 701, label: "7.1+", desc: "WireGuard VPN туннели" },
|
|
||||||
{ name: "Container", minVer: 704, label: "7.4+", desc: "Docker-совместимые контейнеры" },
|
|
||||||
{ name: "BFD", minVer: 705, label: "7.5+", desc: "Bidirectional Forwarding Detection" },
|
|
||||||
{ name: "Large Communities", minVer: 707, label: "7.7+", desc: "BGP Large Communities (RFC 8092)" },
|
|
||||||
{ name: "VXLAN", minVer: 710, label: "7.10+", desc: "VXLAN overlay туннели" },
|
|
||||||
{ name: "RPKI", minVer: 713, label: "7.13+", desc: "Route Origin Validation" },
|
|
||||||
{ name: "BGP Flowspec", minVer: 714, label: "7.14+", desc: "BGP Flow Spec (RFC 8955)" },
|
|
||||||
{ name: "IPv6 Firewall", minVer: 715, label: "7.15+", desc: "Расширенный IPv6 Firewall" },
|
|
||||||
{ name: "REST API v2", minVer: 716, label: "7.16+", desc: "Обновлённый REST API" },
|
|
||||||
{ name: "VRF Enhanced", minVer: 717, label: "7.17+", desc: "Расширенная поддержка VRF" },
|
|
||||||
]
|
|
||||||
|
|
||||||
function RosBadge({ os }: { os: string }) {
|
|
||||||
const v = rosVer(os)
|
|
||||||
const cls = v >= 715
|
|
||||||
? "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/20"
|
|
||||||
: v >= 710
|
|
||||||
? "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/20"
|
|
||||||
: "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/20"
|
|
||||||
return (
|
|
||||||
<span className={cn("text-xs font-mono border rounded px-2 py-0.5", cls)}>
|
|
||||||
{os}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Countries ───────────────────────────────────────────────────────────────
|
// ─── Countries ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const COUNTRIES = [
|
const COUNTRIES = [
|
||||||
@@ -97,90 +71,8 @@ const COUNTRIES = [
|
|||||||
{ code: "NO", label: "Норвегия" },
|
{ code: "NO", label: "Норвегия" },
|
||||||
]
|
]
|
||||||
|
|
||||||
// ─── Type config ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const TYPE_LABELS: Record<ServerType, string> = {
|
|
||||||
"jump-host": "JumpHost",
|
|
||||||
"exit-node": "Exit Node",
|
|
||||||
"home-router": "Home Router",
|
|
||||||
}
|
|
||||||
|
|
||||||
const TYPE_STYLES: Record<ServerType, string> = {
|
|
||||||
"jump-host": "bg-violet-500/10 text-violet-400 border-violet-500/20",
|
|
||||||
"exit-node": "bg-sky-500/10 text-sky-400 border-sky-500/20",
|
|
||||||
"home-router": "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
|
|
||||||
}
|
|
||||||
|
|
||||||
const TYPE_ICONS: Record<ServerType, React.ReactNode> = {
|
|
||||||
"jump-host": <ServerIcon className="size-3 mr-1" />,
|
|
||||||
"exit-node": <NetworkIcon className="size-3 mr-1" />,
|
|
||||||
"home-router": <HomeIcon className="size-3 mr-1" />,
|
|
||||||
}
|
|
||||||
|
|
||||||
function TypeBadge({ type }: { type: ServerType }) {
|
|
||||||
return (
|
|
||||||
<span className={cn(
|
|
||||||
"inline-flex items-center text-xs font-medium border rounded px-2 py-0.5",
|
|
||||||
TYPE_STYLES[type],
|
|
||||||
)}>
|
|
||||||
{TYPE_ICONS[type]}{TYPE_LABELS[type]}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Shared small components ──────────────────────────────────────────────────
|
// ─── Shared small components ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function Field({ label, hint, required, children }: {
|
|
||||||
label: string; hint?: string; required?: boolean; children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-sm font-medium">
|
|
||||||
{label}{required && <span className="text-destructive ml-0.5">*</span>}
|
|
||||||
</label>
|
|
||||||
{children}
|
|
||||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
|
||||||
return (
|
|
||||||
<button type="button" role="switch" aria-checked={checked}
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
|
|
||||||
checked ? "bg-primary" : "bg-input")}>
|
|
||||||
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
|
||||||
checked ? "translate-x-4" : "translate-x-0")} />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 py-0.5">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
|
|
||||||
<div className="flex-1 h-px bg-border" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SegmentedControl<T extends string>({ value, onChange, options }: {
|
|
||||||
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
|
|
||||||
{options.map((o) => (
|
|
||||||
<button key={o.value} type="button" onClick={() => onChange(o.value)}
|
|
||||||
className={cn("px-3 py-1 text-sm rounded transition-colors",
|
|
||||||
value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground")}>
|
|
||||||
{o.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Country field ────────────────────────────────────────────────────────────
|
// ─── Country field ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function CountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
function CountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
@@ -267,30 +159,30 @@ function WanUplinkEditor({ wans, onChange }: {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Field label="Имя" required>
|
<FormField label="Имя" required>
|
||||||
<Input className="h-8 font-mono text-xs" placeholder="WAN1-RT"
|
<Input className="h-8 font-mono text-xs" placeholder="WAN1-RT"
|
||||||
value={wan.name} onChange={e => updateWan(wan.id, { name: e.target.value })} />
|
value={wan.name} onChange={e => updateWan(wan.id, { name: e.target.value })} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Интерфейс">
|
<FormField label="Интерфейс">
|
||||||
<Input className="h-8 font-mono text-xs" placeholder="ether1"
|
<Input className="h-8 font-mono text-xs" placeholder="ether1"
|
||||||
value={wan.iface} onChange={e => updateWan(wan.id, { iface: e.target.value })} />
|
value={wan.iface} onChange={e => updateWan(wan.id, { iface: e.target.value })} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Провайдер (ISP)">
|
<FormField label="Провайдер (ISP)">
|
||||||
<Input className="h-8 text-xs" placeholder="Rostelecom"
|
<Input className="h-8 text-xs" placeholder="Rostelecom"
|
||||||
value={wan.isp} onChange={e => updateWan(wan.id, { isp: e.target.value })} />
|
value={wan.isp} onChange={e => updateWan(wan.id, { isp: e.target.value })} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Внешний IP">
|
<FormField label="Внешний IP">
|
||||||
<Input className="h-8 font-mono text-xs" placeholder="94.25.168.1"
|
<Input className="h-8 font-mono text-xs" placeholder="94.25.168.1"
|
||||||
value={wan.ip} onChange={e => updateWan(wan.id, { ip: e.target.value })} />
|
value={wan.ip} onChange={e => updateWan(wan.id, { ip: e.target.value })} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="↓ Макс. Мбит">
|
<FormField label="↓ Макс. Мбит">
|
||||||
<Input className="h-8 font-mono text-xs" type="number" min={1}
|
<Input className="h-8 font-mono text-xs" type="number" min={1}
|
||||||
value={wan.maxDl} onChange={e => updateWan(wan.id, { maxDl: Number(e.target.value) })} />
|
value={wan.maxDl} onChange={e => updateWan(wan.id, { maxDl: Number(e.target.value) })} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="↑ Макс. Мбит">
|
<FormField label="↑ Макс. Мбит">
|
||||||
<Input className="h-8 font-mono text-xs" type="number" min={1}
|
<Input className="h-8 font-mono text-xs" type="number" min={1}
|
||||||
value={wan.maxUl} onChange={e => updateWan(wan.id, { maxUl: Number(e.target.value) })} />
|
value={wan.maxUl} onChange={e => updateWan(wan.id, { maxUl: Number(e.target.value) })} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -328,10 +220,11 @@ export default function ServersPage() {
|
|||||||
const [_backendOk, setBackendOk] = useState(false)
|
const [_backendOk, setBackendOk] = useState(false)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
||||||
|
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [sheetMode, setSheetMode] = useState<SheetMode>("add")
|
const [sheetMode, setSheetMode] = useState<SheetMode>("add")
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [sheetStep, setSheetStep] = useState(1)
|
||||||
const [form, setForm] = useState<FormState>(defaultForm)
|
const [form, setForm] = useState<FormState>(defaultForm)
|
||||||
const [testState, setTestState] = useState<TestState>("idle")
|
const [testState, setTestState] = useState<TestState>("idle")
|
||||||
const [testMsg, setTestMsg] = useState("")
|
const [testMsg, setTestMsg] = useState("")
|
||||||
@@ -368,6 +261,7 @@ export default function ServersPage() {
|
|||||||
function openAdd() {
|
function openAdd() {
|
||||||
setSheetMode("add"); setEditingId(null)
|
setSheetMode("add"); setEditingId(null)
|
||||||
setForm(defaultForm); setTestState("idle"); setTestMsg("")
|
setForm(defaultForm); setTestState("idle"); setTestMsg("")
|
||||||
|
setSheetStep(1)
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,7 +275,7 @@ export default function ServersPage() {
|
|||||||
lanSubnet: s.lanSubnet ?? "",
|
lanSubnet: s.lanSubnet ?? "",
|
||||||
wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [],
|
wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [],
|
||||||
})
|
})
|
||||||
setTestState("idle"); setTestMsg(""); setOpen(true)
|
setTestState("idle"); setTestMsg(""); setSheetStep(1); setOpen(true)
|
||||||
|
|
||||||
// Fetch full server details (including credentials) from backend
|
// Fetch full server details (including credentials) from backend
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
@@ -549,13 +443,14 @@ export default function ServersPage() {
|
|||||||
// ── derived ──────────────────────────────────────────────────────────────
|
// ── derived ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return serverList.filter(sv => {
|
const base = serverList.filter(sv => {
|
||||||
if (typeFilter !== "all" && sv.type !== typeFilter) return false
|
if (typeFilter !== "all" && sv.type !== typeFilter) return false
|
||||||
if (!search) return true
|
if (!search) return true
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
|
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
|
||||||
})
|
})
|
||||||
}, [serverList, search, typeFilter])
|
return applyReuiFilters(base, advancedFilters, SERVER_FILTER_ACCESSORS)
|
||||||
|
}, [serverList, search, typeFilter, advancedFilters])
|
||||||
|
|
||||||
const counts = useMemo(() => ({
|
const counts = useMemo(() => ({
|
||||||
all: serverList.length,
|
all: serverList.length,
|
||||||
@@ -615,272 +510,34 @@ export default function ServersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<Card>
|
<Card className="overflow-hidden py-0 gap-0">
|
||||||
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
<DataPageToolbar
|
||||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
segmented={{
|
||||||
{tabs.map(tab => (
|
value: typeFilter,
|
||||||
<button key={tab.value} onClick={() => setTypeFilter(tab.value)}
|
onChange: setTypeFilter,
|
||||||
className={cn(
|
options: tabs.map((tab) => ({
|
||||||
"flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors",
|
value: tab.value,
|
||||||
typeFilter === tab.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
label: tab.label,
|
||||||
)}>
|
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
|
||||||
{tab.label}
|
})),
|
||||||
<span className="text-xs tabular-nums opacity-60">
|
}}
|
||||||
{tab.value === "all" ? counts.all : counts[tab.value as ServerType]}
|
filters={advancedFilters}
|
||||||
</span>
|
onFiltersChange={setAdvancedFilters}
|
||||||
</button>
|
filterFields={SERVER_FILTER_FIELDS}
|
||||||
))}
|
search={search}
|
||||||
</div>
|
onSearchChange={setSearch}
|
||||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
|
searchPlaceholder="Поиск по имени, хосту…"
|
||||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
countLabel={`${filtered.length} серверов`}
|
||||||
<input className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
/>
|
||||||
placeholder="Поиск по имени, хосту…" value={search} onChange={e => setSearch(e.target.value)} />
|
<ServersDataGrid
|
||||||
</div>
|
servers={filtered}
|
||||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} серверов</span>
|
isLive={isLive}
|
||||||
</div>
|
pollingIds={pollingIds}
|
||||||
|
onPoll={handlePoll}
|
||||||
<div className="overflow-x-auto">
|
onEdit={openEdit}
|
||||||
<table className="w-full text-sm">
|
onDelete={handleDelete}
|
||||||
<thead>
|
onToggleStatus={handleToggleStatus}
|
||||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
/>
|
||||||
<th className="text-left font-medium px-5 py-3">Имя / Хост</th>
|
|
||||||
<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>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -893,18 +550,45 @@ export default function ServersPage() {
|
|||||||
<SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription>
|
<SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||||
|
<StepperNav className="mb-5">
|
||||||
|
<StepperItem step={1}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>1</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">Основные</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={2}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>2</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">WAN</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={3}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>3</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">API</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={4}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>4</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">Дополнительно</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
</StepperItem>
|
||||||
|
</StepperNav>
|
||||||
|
<StepperPanel className="flex-1 overflow-y-auto">
|
||||||
|
<StepperContent value={1} className="flex flex-col gap-4">
|
||||||
|
|
||||||
{/* 1. Основные */}
|
<FormField label="Имя сервера" required hint="Например home-msk-01">
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<SectionTitle>Основные</SectionTitle>
|
|
||||||
|
|
||||||
<Field label="Имя сервера" required hint="Например home-msk-01">
|
|
||||||
<Input className="font-mono" placeholder="home-msk-01"
|
<Input className="font-mono" placeholder="home-msk-01"
|
||||||
value={form.name} onChange={e => set("name", e.target.value)} />
|
value={form.name} onChange={e => set("name", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Тип узла" required>
|
<FormField label="Тип узла" required>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={form.type}
|
value={form.type}
|
||||||
onChange={v => set("type", v)}
|
onChange={v => set("type", v)}
|
||||||
@@ -914,24 +598,24 @@ export default function ServersPage() {
|
|||||||
{ value: "exit-node", label: "Exit Node" },
|
{ value: "exit-node", label: "Exit Node" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Площадка" required hint="MSK, SPB, FRA…">
|
<FormField label="Площадка" required hint="MSK, SPB, FRA…">
|
||||||
<Input className="font-mono uppercase" placeholder="MSK"
|
<Input className="font-mono uppercase" placeholder="MSK"
|
||||||
value={form.site} onChange={e => set("site", e.target.value.toUpperCase())} />
|
value={form.site} onChange={e => set("site", e.target.value.toUpperCase())} />
|
||||||
</Field>
|
</FormField>
|
||||||
{!isHomeRouter && (
|
{!isHomeRouter && (
|
||||||
<Field label="ASN" hint="Например AS65001">
|
<FormField label="ASN" hint="Например AS65001">
|
||||||
<Input className="font-mono" placeholder="AS65001"
|
<Input className="font-mono" placeholder="AS65001"
|
||||||
value={form.asn} onChange={e => set("asn", e.target.value)} />
|
value={form.asn} onChange={e => set("asn", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
{isHomeRouter && (
|
{isHomeRouter && (
|
||||||
<Field label="LAN-подсеть" hint="Например 192.168.10.0/24">
|
<FormField label="LAN-подсеть" hint="Например 192.168.10.0/24">
|
||||||
<Input className="font-mono" placeholder="192.168.10.0/24"
|
<Input className="font-mono" placeholder="192.168.10.0/24"
|
||||||
value={form.lanSubnet} onChange={e => set("lanSubnet", e.target.value)} />
|
value={form.lanSubnet} onChange={e => set("lanSubnet", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -939,45 +623,43 @@ export default function ServersPage() {
|
|||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">Включён</span>
|
<span className="text-sm font-medium">Включён</span>
|
||||||
<Toggle checked={form.enabled} onChange={v => set("enabled", v)} />
|
<FormToggle checked={form.enabled} onChange={v => set("enabled", v)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</StepperContent>
|
||||||
|
<StepperContent value={2} className="flex flex-col gap-4">
|
||||||
{/* 2. WAN-аплинки (только для home-router) */}
|
|
||||||
{isHomeRouter && (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<SectionTitle>WAN-аплинки</SectionTitle>
|
<SectionTitle>WAN-аплинки</SectionTitle>
|
||||||
|
{!isHomeRouter ? (
|
||||||
|
<p className="text-sm text-muted-foreground">WAN-аплинки доступны только для типа Home Router.</p>
|
||||||
|
) : (
|
||||||
<WanUplinkEditor
|
<WanUplinkEditor
|
||||||
wans={form.wanUplinks}
|
wans={form.wanUplinks}
|
||||||
onChange={wans => set("wanUplinks", wans)}
|
onChange={wans => set("wanUplinks", wans)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
)}
|
</StepperContent>
|
||||||
|
<StepperContent value={3} className="flex flex-col gap-4">
|
||||||
{/* 3. Подключение (API) */}
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>
|
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>
|
||||||
|
|
||||||
<Field label="Хост / IP-адрес" required
|
<FormField label="Хост / IP-адрес" required
|
||||||
hint={isHomeRouter
|
hint={isHomeRouter
|
||||||
? "Управляющий LAN-адрес роутера, например 192.168.10.1"
|
? "Управляющий LAN-адрес роутера, например 192.168.10.1"
|
||||||
: "Внешний или управляющий IP-адрес роутера"}>
|
: "Внешний или управляющий IP-адрес роутера"}>
|
||||||
<Input className="font-mono" placeholder={isHomeRouter ? "192.168.10.1" : "203.0.113.1"}
|
<Input className="font-mono" placeholder={isHomeRouter ? "192.168.10.1" : "203.0.113.1"}
|
||||||
value={form.host} onChange={e => set("host", e.target.value)} />
|
value={form.host} onChange={e => set("host", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Протокол">
|
<FormField label="Протокол">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={form.proto}
|
value={form.proto}
|
||||||
onChange={v => { set("proto", v); set("port", v === "https" ? "443" : "80") }}
|
onChange={v => { set("proto", v); set("port", v === "https" ? "443" : "80") }}
|
||||||
options={[{ value: "https", label: "HTTPS" }, { value: "http", label: "HTTP" }]}
|
options={[{ value: "https", label: "HTTPS" }, { value: "http", label: "HTTP" }]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Порт" hint="443 / 80">
|
<FormField label="Порт" hint="443 / 80">
|
||||||
<Input className="font-mono" placeholder="443"
|
<Input className="font-mono" placeholder="443"
|
||||||
value={form.port} onChange={e => set("port", e.target.value)} />
|
value={form.port} onChange={e => set("port", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -988,13 +670,13 @@ export default function ServersPage() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">Отключить для self-signed сертификатов</p>
|
<p className="text-xs text-muted-foreground">Отключить для self-signed сертификатов</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={form.verifySsl} onChange={v => set("verifySsl", v)} />
|
<FormToggle checked={form.verifySsl} onChange={v => set("verifySsl", v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Путь API">
|
<FormField label="Путь API">
|
||||||
<Input className="font-mono" placeholder="/rest"
|
<Input className="font-mono" placeholder="/rest"
|
||||||
value={form.apiPath} onChange={e => set("apiPath", e.target.value)} />
|
value={form.apiPath} onChange={e => set("apiPath", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
|
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
|
||||||
<p className="font-medium text-foreground mb-1">RouterOS 7.1+ REST API</p>
|
<p className="font-medium text-foreground mb-1">RouterOS 7.1+ REST API</p>
|
||||||
@@ -1008,12 +690,12 @@ export default function ServersPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Имя пользователя" required hint="Пользователь RouterOS с доступом к API">
|
<FormField label="Имя пользователя" required hint="Пользователь RouterOS с доступом к API">
|
||||||
<Input className="font-mono" placeholder="api-user"
|
<Input className="font-mono" placeholder="api-user"
|
||||||
value={form.username} onChange={e => set("username", e.target.value)} />
|
value={form.username} onChange={e => set("username", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Пароль" required>
|
<FormField label="Пароль" required>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input type={form.showPassword ? "text" : "password"}
|
<Input type={form.showPassword ? "text" : "password"}
|
||||||
className="font-mono pr-9" placeholder="Пароль пользователя RouterOS"
|
className="font-mono pr-9" placeholder="Пароль пользователя RouterOS"
|
||||||
@@ -1024,7 +706,7 @@ export default function ServersPage() {
|
|||||||
{form.showPassword ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
{form.showPassword ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" className="w-fit gap-2"
|
<Button type="button" variant="outline" size="sm" className="w-fit gap-2"
|
||||||
@@ -1046,10 +728,8 @@ export default function ServersPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</StepperContent>
|
||||||
|
<StepperContent value={4} className="flex flex-col gap-4">
|
||||||
{/* 4. Дополнительно */}
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}
|
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}
|
||||||
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors">
|
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors">
|
||||||
{form.showAdvanced ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
|
{form.showAdvanced ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
|
||||||
@@ -1059,34 +739,41 @@ export default function ServersPage() {
|
|||||||
{form.showAdvanced && (
|
{form.showAdvanced && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="SSH-порт">
|
<FormField label="SSH-порт">
|
||||||
<Input type="number" className="font-mono" value={form.sshPort}
|
<Input type="number" className="font-mono" value={form.sshPort}
|
||||||
onChange={e => set("sshPort", Number(e.target.value))} />
|
onChange={e => set("sshPort", Number(e.target.value))} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Winbox-порт">
|
<FormField label="Winbox-порт">
|
||||||
<Input type="number" className="font-mono" value={form.winboxPort}
|
<Input type="number" className="font-mono" value={form.winboxPort}
|
||||||
onChange={e => set("winboxPort", Number(e.target.value))} />
|
onChange={e => set("winboxPort", Number(e.target.value))} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<Field label="Таймаут соединения, с">
|
<FormField label="Таймаут соединения, с">
|
||||||
<Input type="number" className="font-mono" value={form.timeout}
|
<Input type="number" className="font-mono" value={form.timeout}
|
||||||
onChange={e => set("timeout", Number(e.target.value))} />
|
onChange={e => set("timeout", Number(e.target.value))} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Комментарий">
|
<FormField label="Комментарий">
|
||||||
<Input placeholder="Описание или заметка" value={form.comment}
|
<Input placeholder="Описание или заметка" value={form.comment}
|
||||||
onChange={e => set("comment", e.target.value)} />
|
onChange={e => set("comment", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</StepperContent>
|
||||||
|
</StepperPanel>
|
||||||
</div>
|
</Stepper>
|
||||||
|
|
||||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
|
||||||
<Button className="flex-1" onClick={handleSave}>
|
{sheetStep > 1 && (
|
||||||
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
|
||||||
</Button>
|
)}
|
||||||
|
{sheetStep < 4 ? (
|
||||||
|
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
|
||||||
|
) : (
|
||||||
|
<Button className="ml-auto" onClick={handleSave}>
|
||||||
|
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormField, FormToggle } from "@/components/form-kit"
|
||||||
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import {
|
import {
|
||||||
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
||||||
} from "@/components/ui/card"
|
} from "@/components/ui/card"
|
||||||
@@ -162,17 +164,6 @@ function makeApiFetch(backendUrl: string) {
|
|||||||
|
|
||||||
// ─── small components ─────────────────────────────────────────────────────────
|
// ─── small components ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
|
||||||
return (
|
|
||||||
<button type="button" onClick={() => onChange(!checked)}
|
|
||||||
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
|
|
||||||
checked ? "bg-primary" : "bg-input")}>
|
|
||||||
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
|
||||||
checked ? "translate-x-4" : "translate-x-0")} />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
|
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-4 py-3.5">
|
<div className="flex items-center justify-between gap-4 py-3.5">
|
||||||
@@ -413,24 +404,24 @@ function UserSheet({ open, user, onSave, onClose }: {
|
|||||||
{tab === "profile" && (
|
{tab === "profile" && (
|
||||||
<div className="px-5 py-5 flex flex-col gap-4">
|
<div className="px-5 py-5 flex flex-col gap-4">
|
||||||
|
|
||||||
<Field label="Полное имя" error={errors.name}>
|
<FormField label="Полное имя" error={errors.name}>
|
||||||
<Input value={form.name} onChange={e => setField("name", e.target.value)}
|
<Input value={form.name} onChange={e => setField("name", e.target.value)}
|
||||||
placeholder="Иван Иванов" className="h-9" />
|
placeholder="Иван Иванов" className="h-9" />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Логин" error={errors.login}>
|
<FormField label="Логин" error={errors.login}>
|
||||||
<Input value={form.login} onChange={e => setField("login", e.target.value)}
|
<Input value={form.login} onChange={e => setField("login", e.target.value)}
|
||||||
placeholder="i.ivanov" className="h-9 font-mono" />
|
placeholder="i.ivanov" className="h-9 font-mono" />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Email" error={errors.email}>
|
<FormField label="Email" error={errors.email}>
|
||||||
<Input value={form.email} onChange={e => setField("email", e.target.value)}
|
<Input value={form.email} onChange={e => setField("email", e.target.value)}
|
||||||
placeholder="[email protected]" type="email" className="h-9" />
|
placeholder="[email protected]" type="email" className="h-9" />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<Field label="Роль">
|
<FormField label="Роль">
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{(["viewer", "operator", "admin"] as Role[]).map(r => (
|
{(["viewer", "operator", "admin"] as Role[]).map(r => (
|
||||||
<button key={r} type="button" onClick={() => setRole(r)}
|
<button key={r} type="button" onClick={() => setRole(r)}
|
||||||
@@ -453,18 +444,18 @@ function UserSheet({ open, user, onSave, onClose }: {
|
|||||||
? "Управление инфраструктурой согласно выданным правам"
|
? "Управление инфраструктурой согласно выданным правам"
|
||||||
: "Только просмотр согласно выданным правам"}
|
: "Только просмотр согласно выданным правам"}
|
||||||
</p>
|
</p>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<Field label="Статус учётной записи">
|
<FormField label="Статус учётной записи">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Toggle checked={form.active} onChange={v => setField("active", v)} />
|
<FormToggle checked={form.active} onChange={v => setField("active", v)} />
|
||||||
<span className={cn("text-xs font-medium", form.active ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground")}>
|
<span className={cn("text-xs font-medium", form.active ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground")}>
|
||||||
{form.active ? "Активна" : "Заблокирована"}
|
{form.active ? "Активна" : "Заблокирована"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -660,7 +651,7 @@ function UserSheet({ open, user, onSave, onClose }: {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* active toggle */}
|
{/* active toggle */}
|
||||||
<Toggle checked={su.active} onChange={() => toggleSubUser(su.id)} />
|
<FormToggle checked={su.active} onChange={() => toggleSubUser(su.id)} />
|
||||||
|
|
||||||
{/* delete */}
|
{/* delete */}
|
||||||
<button onClick={() => removeSubUser(su.id)}
|
<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 ───────────────────────────────────────────────────────────
|
// ─── delete confirm ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function DatabaseRestoreConfirm({
|
function DatabaseRestoreConfirm({
|
||||||
@@ -900,7 +875,7 @@ export default function SettingsPage() {
|
|||||||
const [dbBackupBusy, setDbBackupBusy] = useState(false)
|
const [dbBackupBusy, setDbBackupBusy] = useState(false)
|
||||||
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
|
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
|
||||||
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
|
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
|
||||||
const dbRestoreInputRef = useRef<HTMLInputElement>(null)
|
const [dbRestoreDialogOpen, setDbRestoreDialogOpen] = useState(false)
|
||||||
|
|
||||||
// notifications
|
// notifications
|
||||||
const [notifEmail, setNotifEmail] = useState(true)
|
const [notifEmail, setNotifEmail] = useState(true)
|
||||||
@@ -1031,7 +1006,6 @@ export default function SettingsPage() {
|
|||||||
await restoreSystemDatabaseBackup(backendUrl, dbRestoreFile)
|
await restoreSystemDatabaseBackup(backendUrl, dbRestoreFile)
|
||||||
toast.success("База приложения восстановлена")
|
toast.success("База приложения восстановлена")
|
||||||
setDbRestoreFile(null)
|
setDbRestoreFile(null)
|
||||||
if (dbRestoreInputRef.current) dbRestoreInputRef.current.value = ""
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Не удалось восстановить базу")
|
toast.error(e instanceof Error ? e.message : "Не удалось восстановить базу")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1230,30 +1204,16 @@ export default function SettingsPage() {
|
|||||||
description="Полностью заменяет текущую базу SQLite"
|
description="Полностью заменяет текущую базу SQLite"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex flex-col items-end gap-2">
|
||||||
<Input
|
<Button
|
||||||
ref={dbRestoreInputRef}
|
variant="outline"
|
||||||
type="file"
|
size="sm"
|
||||||
accept=".db,.sqlite,.sqlite3,application/octet-stream"
|
className="h-8"
|
||||||
className="hidden"
|
|
||||||
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
|
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
|
||||||
onChange={(e) => {
|
onClick={() => setDbRestoreDialogOpen(true)}
|
||||||
const file = e.target.files?.[0] ?? null
|
>
|
||||||
if (!file) return
|
<UploadIcon className="size-4" />
|
||||||
setDbRestoreFile(file)
|
Выбрать файл
|
||||||
}}
|
</Button>
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
{dbRestoreFile && (
|
{dbRestoreFile && (
|
||||||
<p className="text-xs text-muted-foreground max-w-[220px] text-right break-all">{dbRestoreFile.name}</p>
|
<p className="text-xs text-muted-foreground max-w-[220px] text-right break-all">{dbRestoreFile.name}</p>
|
||||||
)}
|
)}
|
||||||
@@ -1384,7 +1344,7 @@ export default function SettingsPage() {
|
|||||||
label="Подставлять данные EvoBGP"
|
label="Подставлять данные EvoBGP"
|
||||||
description="На страницах Домены, IP-диапазоны, ASN и Communities вместо моков из lib/data"
|
description="На страницах Домены, IP-диапазоны, ASN и Communities вместо моков из lib/data"
|
||||||
>
|
>
|
||||||
<Toggle
|
<FormToggle
|
||||||
checked={evoEnabledDraft}
|
checked={evoEnabledDraft}
|
||||||
onChange={(v) => setEvoEnabledDraft(v)}
|
onChange={(v) => setEvoEnabledDraft(v)}
|
||||||
/>
|
/>
|
||||||
@@ -1496,15 +1456,15 @@ export default function SettingsPage() {
|
|||||||
<CardHeader><CardTitle className="text-base">Каналы уведомлений</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-base">Каналы уведомлений</CardTitle></CardHeader>
|
||||||
<CardContent className="divide-y px-5">
|
<CardContent className="divide-y px-5">
|
||||||
<SettingRow label="Email" description="Отправка уведомлений на [email protected]">
|
<SettingRow label="Email" description="Отправка уведомлений на [email protected]">
|
||||||
<Toggle checked={notifEmail} onChange={setNotifEmail} />
|
<FormToggle checked={notifEmail} onChange={setNotifEmail} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
{notifEmail && <div className="py-3"><Input className="text-sm h-8" defaultValue="[email protected]" /></div>}
|
{notifEmail && <div className="py-3"><Input className="text-sm h-8" defaultValue="[email protected]" /></div>}
|
||||||
<SettingRow label="Slack" description="Webhook-интеграция с каналом #alerts">
|
<SettingRow label="Slack" description="Webhook-интеграция с каналом #alerts">
|
||||||
<Toggle checked={notifSlack} onChange={setNotifSlack} />
|
<FormToggle checked={notifSlack} onChange={setNotifSlack} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
{notifSlack && <div className="py-3"><Input className="text-sm h-8 font-mono" placeholder="https://hooks.slack.com/…" /></div>}
|
{notifSlack && <div className="py-3"><Input className="text-sm h-8 font-mono" placeholder="https://hooks.slack.com/…" /></div>}
|
||||||
<SettingRow label="Webhook" description="POST-запрос на произвольный endpoint">
|
<SettingRow label="Webhook" description="POST-запрос на произвольный endpoint">
|
||||||
<Toggle checked={notifWh} onChange={setNotifWh} />
|
<FormToggle checked={notifWh} onChange={setNotifWh} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
{notifWh && <div className="py-3"><Input className="text-sm h-8 font-mono" defaultValue="https://hooks.example.com/routerlists" /></div>}
|
{notifWh && <div className="py-3"><Input className="text-sm h-8 font-mono" defaultValue="https://hooks.example.com/routerlists" /></div>}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -1513,16 +1473,16 @@ export default function SettingsPage() {
|
|||||||
<CardHeader><CardTitle className="text-base">Триггеры</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-base">Триггеры</CardTitle></CardHeader>
|
||||||
<CardContent className="divide-y px-5">
|
<CardContent className="divide-y px-5">
|
||||||
<SettingRow label="Деградация узла" description="Потери пакетов > 5% или RTT > 100мс">
|
<SettingRow label="Деградация узла" description="Потери пакетов > 5% или RTT > 100мс">
|
||||||
<Toggle checked={notifDegr} onChange={setNotifDegr} />
|
<FormToggle checked={notifDegr} onChange={setNotifDegr} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow label="Узел ушёл offline">
|
<SettingRow label="Узел ушёл offline">
|
||||||
<Toggle checked={notifOffline} onChange={setNotifOffline} />
|
<FormToggle checked={notifOffline} onChange={setNotifOffline} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow label="Падение BGP-сессии">
|
<SettingRow label="Падение BGP-сессии">
|
||||||
<Toggle checked={notifBgp} onChange={setNotifBgp} />
|
<FormToggle checked={notifBgp} onChange={setNotifBgp} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow label="Просроченный бэкап" description="Если последний бэкап старше 2 дней">
|
<SettingRow label="Просроченный бэкап" description="Если последний бэкап старше 2 дней">
|
||||||
<Toggle checked={notifBackup} onChange={setNotifBackup} />
|
<FormToggle checked={notifBackup} onChange={setNotifBackup} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1741,7 +1701,7 @@ export default function SettingsPage() {
|
|||||||
<CardContent className="divide-y px-5">
|
<CardContent className="divide-y px-5">
|
||||||
<SettingRow label="Двухфакторная аутентификация (MFA)"
|
<SettingRow label="Двухфакторная аутентификация (MFA)"
|
||||||
description="TOTP / Authenticator app для всех администраторов">
|
description="TOTP / Authenticator app для всех администраторов">
|
||||||
<Toggle checked={mfa} onChange={setMfa} />
|
<FormToggle checked={mfa} onChange={setMfa} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow label="Тайм-аут сессии (мин)" description="Автоматический выход при бездействии">
|
<SettingRow label="Тайм-аут сессии (мин)" description="Автоматический выход при бездействии">
|
||||||
<Input className="w-20 h-8 text-sm" value={sessMin} onChange={e => setSessMin(e.target.value)} />
|
<Input className="w-20 h-8 text-sm" value={sessMin} onChange={e => setSessMin(e.target.value)} />
|
||||||
@@ -1763,7 +1723,7 @@ export default function SettingsPage() {
|
|||||||
<CardContent className="divide-y px-5">
|
<CardContent className="divide-y px-5">
|
||||||
<SettingRow label="Расширенный журнал аудита"
|
<SettingRow label="Расширенный журнал аудита"
|
||||||
description="Записывать все изменения конфигурации с указанием пользователя и IP">
|
description="Записывать все изменения конфигурации с указанием пользователя и IP">
|
||||||
<Toggle checked={auditLog} onChange={setAuditLog} />
|
<FormToggle checked={auditLog} onChange={setAuditLog} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1853,7 +1813,6 @@ export default function SettingsPage() {
|
|||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (dbRestoreBusy) return
|
if (dbRestoreBusy) return
|
||||||
setDbRestoreFile(null)
|
setDbRestoreFile(null)
|
||||||
if (dbRestoreInputRef.current) dbRestoreInputRef.current.value = ""
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1864,6 +1823,19 @@ export default function SettingsPage() {
|
|||||||
onCancel={() => setDeleteTarget(null)}
|
onCancel={() => setDeleteTarget(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<FileImportDialog
|
||||||
|
open={dbRestoreDialogOpen}
|
||||||
|
onOpenChange={setDbRestoreDialogOpen}
|
||||||
|
title="Восстановление базы данных"
|
||||||
|
description="Выберите файл SQLite (.db) — текущая база будет полностью заменена"
|
||||||
|
accept=".db,.sqlite,.sqlite3,application/octet-stream"
|
||||||
|
onImport={async (files) => {
|
||||||
|
const file = files[0]
|
||||||
|
if (!file) return
|
||||||
|
setDbRestoreFile(file)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-83
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useMemo, useEffect, useRef, useCallback } from "react"
|
import { useState, useMemo, useEffect, useRef, useCallback } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -212,21 +213,6 @@ function probeGroupActionKey(srvId: string, group: { name: string; target: strin
|
|||||||
|
|
||||||
// ── shared components ──────────────────────────────────────────────────────────
|
// ── shared components ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
|
||||||
return (
|
|
||||||
<button type="button" onClick={() => onChange(!checked)}
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
|
|
||||||
checked ? "bg-primary" : "bg-input",
|
|
||||||
)}>
|
|
||||||
<span className={cn(
|
|
||||||
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
|
||||||
checked ? "translate-x-4" : "translate-x-0",
|
|
||||||
)} />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
|
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
|
||||||
return (
|
return (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
@@ -267,18 +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 */
|
/** Активный интерфейс RouterOS: не disabled и running */
|
||||||
function isActiveRouterOsInterface(i: { running?: boolean; disabled?: boolean }): boolean {
|
function isActiveRouterOsInterface(i: { running?: boolean; disabled?: boolean }): boolean {
|
||||||
return i.running === true && i.disabled !== true
|
return i.running === true && i.disabled !== true
|
||||||
@@ -323,36 +297,6 @@ function interfaceOptionMatchesSearch(iface: RouterInterfaceOption, raw: string)
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function SegmentedControl<T extends string>({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
}: {
|
|
||||||
value: T
|
|
||||||
onChange: (v: T) => void
|
|
||||||
options: Array<{ value: T; label: string }>
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
|
|
||||||
{options.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(option.value)}
|
|
||||||
className={cn(
|
|
||||||
"px-3 py-1 text-sm rounded transition-colors",
|
|
||||||
value === option.value
|
|
||||||
? "bg-background text-foreground shadow-sm"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ServerPickerCards({
|
function ServerPickerCards({
|
||||||
options,
|
options,
|
||||||
selectedId,
|
selectedId,
|
||||||
@@ -2401,7 +2345,7 @@ export default function UptimePage() {
|
|||||||
className={cn("px-4 py-3 hover:bg-muted/20 transition-colors", !probe.enabled && "opacity-50")}>
|
className={cn("px-4 py-3 hover:bg-muted/20 transition-colors", !probe.enabled && "opacity-50")}>
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
{/* enable toggle */}
|
{/* enable toggle */}
|
||||||
<Toggle checked={probe.enabled} onChange={(v) => updateSpeedProbe(probe.id, { enabled: v })} />
|
<FormToggle checked={probe.enabled} onChange={(v) => updateSpeedProbe(probe.id, { enabled: v })} />
|
||||||
|
|
||||||
{/* route: src → dst */}
|
{/* route: src → dst */}
|
||||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
@@ -2824,7 +2768,7 @@ export default function UptimePage() {
|
|||||||
!p.enabled && "opacity-40",
|
!p.enabled && "opacity-40",
|
||||||
)}
|
)}
|
||||||
style={{ gridTemplateColumns: "36px 16px 130px 120px 140px 70px 44px minmax(132px,1fr) 96px 36px 72px" }}>
|
style={{ gridTemplateColumns: "36px 16px 130px 120px 140px 70px 44px minmax(132px,1fr) 96px 36px 72px" }}>
|
||||||
<Toggle checked={p.enabled} onChange={v => toggleProbe(p.id, v)} />
|
<FormToggle checked={p.enabled} onChange={v => toggleProbe(p.id, v)} />
|
||||||
<StatusDot
|
<StatusDot
|
||||||
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
|
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
|
||||||
pulse={p.status === "up" && p.enabled}
|
pulse={p.status === "up" && p.enabled}
|
||||||
@@ -3002,7 +2946,7 @@ export default function UptimePage() {
|
|||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-5">
|
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-5">
|
||||||
<Field label="Источник">
|
<FormField label="Источник">
|
||||||
<ServerPickerCards
|
<ServerPickerCards
|
||||||
options={selectableSources}
|
options={selectableSources}
|
||||||
selectedId={speedDraft.srcServerId}
|
selectedId={speedDraft.srcServerId}
|
||||||
@@ -3015,9 +2959,9 @@ export default function UptimePage() {
|
|||||||
void loadSpeedInterfaces(nextSrc)
|
void loadSpeedInterfaces(nextSrc)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Назначение">
|
<FormField label="Назначение">
|
||||||
<ServerPickerCards
|
<ServerPickerCards
|
||||||
options={selectableSources.filter((s) => s.id !== speedDraft.srcServerId)}
|
options={selectableSources.filter((s) => s.id !== speedDraft.srcServerId)}
|
||||||
selectedId={speedDraft.dstServerId}
|
selectedId={speedDraft.dstServerId}
|
||||||
@@ -3026,28 +2970,28 @@ export default function UptimePage() {
|
|||||||
void loadSpeedInterfaces(nextDst)
|
void loadSpeedInterfaces(nextDst)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Интерфейс источника">
|
<FormField label="Интерфейс источника">
|
||||||
<InterfacePickerCards
|
<InterfacePickerCards
|
||||||
value={speedDraft.srcInterface}
|
value={speedDraft.srcInterface}
|
||||||
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, srcInterface: v }))}
|
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, srcInterface: v }))}
|
||||||
options={filterActiveInterfaces(speedIfaces[speedDraft.srcServerId] ?? [])}
|
options={filterActiveInterfaces(speedIfaces[speedDraft.srcServerId] ?? [])}
|
||||||
autoLabel="auto"
|
autoLabel="auto"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Интерфейс назначения">
|
<FormField label="Интерфейс назначения">
|
||||||
<InterfacePickerCards
|
<InterfacePickerCards
|
||||||
value={speedDraft.dstInterface}
|
value={speedDraft.dstInterface}
|
||||||
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, dstInterface: v }))}
|
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, dstInterface: v }))}
|
||||||
options={filterActiveInterfaces(speedIfaces[speedDraft.dstServerId] ?? [])}
|
options={filterActiveInterfaces(speedIfaces[speedDraft.dstServerId] ?? [])}
|
||||||
autoLabel="auto"
|
autoLabel="auto"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<Field label="Протокол">
|
<FormField label="Протокол">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={speedDraft.protocol}
|
value={speedDraft.protocol}
|
||||||
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, protocol: v }))}
|
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, protocol: v }))}
|
||||||
@@ -3056,8 +3000,8 @@ export default function UptimePage() {
|
|||||||
{ value: "udp", label: "UDP" },
|
{ value: "udp", label: "UDP" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Direction">
|
<FormField label="Direction">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={speedDraft.direction}
|
value={speedDraft.direction}
|
||||||
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, direction: v }))}
|
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, direction: v }))}
|
||||||
@@ -3067,10 +3011,10 @@ export default function UptimePage() {
|
|||||||
{ value: "receive", label: "rx" },
|
{ value: "receive", label: "rx" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Сек">
|
<FormField label="Сек">
|
||||||
<Input className="h-9" value={speedDraft.durationSec} onChange={(e) => setSpeedDraft((prev) => ({ ...prev, durationSec: e.target.value }))} />
|
<Input className="h-9" value={speedDraft.durationSec} onChange={(e) => setSpeedDraft((prev) => ({ ...prev, durationSec: e.target.value }))} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{speedDraft.srcServerId &&
|
{speedDraft.srcServerId &&
|
||||||
@@ -3169,7 +3113,7 @@ export default function UptimePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field
|
<FormField
|
||||||
label="Источник (кто пингует)"
|
label="Источник (кто пингует)"
|
||||||
hint="— весь каталог, в т.ч. выключенные в инвентаре (Home Router часто «выкл.», но доступен по LAN для ping)"
|
hint="— весь каталог, в т.ч. выключенные в инвентаре (Home Router часто «выкл.», но доступен по LAN для ping)"
|
||||||
>
|
>
|
||||||
@@ -3178,9 +3122,9 @@ export default function UptimePage() {
|
|||||||
selectedId={newSrcId}
|
selectedId={newSrcId}
|
||||||
onSelect={(id) => setNewSrcId(id)}
|
onSelect={(id) => setNewSrcId(id)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Интерфейс источника" hint="(необязательно)">
|
<FormField label="Интерфейс источника" hint="(необязательно)">
|
||||||
<InterfacePickerCards
|
<InterfacePickerCards
|
||||||
value={newSrcInterface}
|
value={newSrcInterface}
|
||||||
onChange={setNewSrcInterface}
|
onChange={setNewSrcInterface}
|
||||||
@@ -3188,25 +3132,25 @@ export default function UptimePage() {
|
|||||||
autoLabel="авто (по маршруту)"
|
autoLabel="авто (по маршруту)"
|
||||||
busy={srcInterfacesBusy}
|
busy={srcInterfacesBusy}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Имя пробы">
|
<FormField label="Имя пробы">
|
||||||
<Input className="h-9 text-sm" placeholder="youtube.com"
|
<Input className="h-9 text-sm" placeholder="youtube.com"
|
||||||
value={newName} onChange={e => setNewName(e.target.value)} />
|
value={newName} onChange={e => setNewName(e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Целевой IP / хост">
|
<FormField label="Целевой IP / хост">
|
||||||
<Input className="h-9 text-sm font-mono" placeholder="142.250.74.110"
|
<Input className="h-9 text-sm font-mono" placeholder="142.250.74.110"
|
||||||
value={newTarget} onChange={e => setNewTarget(e.target.value)} />
|
value={newTarget} onChange={e => setNewTarget(e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Связанный фильтр" hint="(необязательно)">
|
<FormField label="Связанный фильтр" hint="(необязательно)">
|
||||||
<LinkedFilterPickerCards
|
<LinkedFilterPickerCards
|
||||||
value={newFilter}
|
value={newFilter}
|
||||||
onChange={setNewFilter}
|
onChange={setNewFilter}
|
||||||
items={filters}
|
items={filters}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
|
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
import { servers } from "@/lib/data"
|
import { servers } from "@/lib/data"
|
||||||
import type { WireGuardInterface, WireGuardPeer } from "@/lib/data"
|
import type { WireGuardInterface, WireGuardPeer } from "@/lib/data"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
@@ -410,11 +411,12 @@ export default function WireGuardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
<EmptyState
|
||||||
<ShieldCheckIcon className="size-10 mb-3 opacity-20" />
|
icon={<ShieldCheckIcon className="size-4" />}
|
||||||
<p className="text-sm font-medium">Нет WireGuard интерфейсов</p>
|
title="Нет WireGuard интерфейсов"
|
||||||
<p className="text-xs mt-1">Добавьте первый интерфейс или проверьте поиск</p>
|
description="Добавьте первый интерфейс или проверьте поиск"
|
||||||
</div>
|
className="border-0 py-16"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((iface) => (
|
filtered.map((iface) => (
|
||||||
<IfaceRow
|
<IfaceRow
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { NextRequest } from "next/server"
|
||||||
|
import { proxyBackendRequest } from "@/lib/proxy-backend-request"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
export const maxDuration = 600
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return proxyBackendRequest(request, "/api/system/database/backup", {
|
||||||
|
method: "GET",
|
||||||
|
forwardRequestBody: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { NextRequest } from "next/server"
|
||||||
|
import { proxyBackendRequest } from "@/lib/proxy-backend-request"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
export const maxDuration = 600
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
return proxyBackendRequest(request, "/api/system/database/restore", { method: "POST" })
|
||||||
|
}
|
||||||
@@ -39,6 +39,15 @@
|
|||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-info: var(--info);
|
||||||
|
--color-info-foreground: var(--info-foreground);
|
||||||
|
--color-success: var(--success);
|
||||||
|
--color-success-foreground: var(--success-foreground);
|
||||||
|
--color-warning: var(--warning);
|
||||||
|
--color-warning-foreground: var(--warning-foreground);
|
||||||
|
--color-invert: var(--invert);
|
||||||
|
--color-invert-foreground: var(--invert-foreground);
|
||||||
--radius-sm: calc(var(--radius) * 0.6);
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
@@ -74,6 +83,15 @@
|
|||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--destructive-foreground: var(--color-red-800);
|
||||||
|
--info: var(--color-violet-500);
|
||||||
|
--info-foreground: var(--color-violet-900);
|
||||||
|
--success: var(--color-emerald-500);
|
||||||
|
--success-foreground: var(--color-emerald-900);
|
||||||
|
--warning: var(--color-yellow-500);
|
||||||
|
--warning-foreground: var(--color-yellow-900);
|
||||||
|
--invert: var(--color-zinc-900);
|
||||||
|
--invert-foreground: var(--color-zinc-50);
|
||||||
|
|
||||||
/* Borders + inputs */
|
/* Borders + inputs */
|
||||||
--border: oklch(0.904 0.006 264.0);
|
--border: oklch(0.904 0.006 264.0);
|
||||||
@@ -148,6 +166,15 @@
|
|||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--destructive-foreground: var(--color-red-600);
|
||||||
|
--info: var(--color-violet-500);
|
||||||
|
--info-foreground: var(--color-violet-600);
|
||||||
|
--success: var(--color-emerald-500);
|
||||||
|
--success-foreground: var(--color-emerald-600);
|
||||||
|
--warning: var(--color-yellow-500);
|
||||||
|
--warning-foreground: var(--color-yellow-600);
|
||||||
|
--invert: var(--color-zinc-700);
|
||||||
|
--invert-foreground: var(--color-zinc-50);
|
||||||
|
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
|||||||
// ── app factory ────────────────────────────────────────────────────────────────
|
// ── app factory ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
|
bodyLimit: 512 * 1024 * 1024,
|
||||||
|
requestTimeout: 10 * 60 * 1000,
|
||||||
logger: {
|
logger: {
|
||||||
transport: {
|
transport: {
|
||||||
target: "pino-pretty",
|
target: "pino-pretty",
|
||||||
|
|||||||
+3
-1
@@ -21,5 +21,7 @@
|
|||||||
},
|
},
|
||||||
"menuColor": "default",
|
"menuColor": "default",
|
||||||
"menuAccent": "subtle",
|
"menuAccent": "subtle",
|
||||||
"registries": {}
|
"registries": {
|
||||||
|
"@reui": "https://reui.io/r/{style}/{name}.json"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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 }
|
||||||
@@ -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 }
|
||||||
@@ -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
@@ -1,8 +1,24 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
import { Card } from "@/components/ui/card"
|
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> {
|
export interface Column<T> {
|
||||||
key: string
|
key: string
|
||||||
@@ -15,6 +31,9 @@ interface DataTableProps<T extends { id: string }> {
|
|||||||
columns: Column<T>[]
|
columns: Column<T>[]
|
||||||
searchPlaceholder?: string
|
searchPlaceholder?: string
|
||||||
searchKeys?: (keyof T)[]
|
searchKeys?: (keyof T)[]
|
||||||
|
isLoading?: boolean
|
||||||
|
emptyTitle?: string
|
||||||
|
emptyDescription?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTable<T extends { id: string }>({
|
export function DataTable<T extends { id: string }>({
|
||||||
@@ -22,10 +41,14 @@ export function DataTable<T extends { id: string }>({
|
|||||||
columns,
|
columns,
|
||||||
searchPlaceholder = "Поиск…",
|
searchPlaceholder = "Поиск…",
|
||||||
searchKeys = [],
|
searchKeys = [],
|
||||||
|
isLoading = false,
|
||||||
|
emptyTitle = "Нет записей",
|
||||||
|
emptyDescription,
|
||||||
}: DataTableProps<T>) {
|
}: DataTableProps<T>) {
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
|
const [globalFilter, setGlobalFilter] = useState("")
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
if (!search || searchKeys.length === 0) return data
|
if (!search || searchKeys.length === 0) return data
|
||||||
const s = search.toLowerCase()
|
const s = search.toLowerCase()
|
||||||
return data.filter((row) =>
|
return data.filter((row) =>
|
||||||
@@ -33,44 +56,60 @@ export function DataTable<T extends { id: string }>({
|
|||||||
)
|
)
|
||||||
}, [data, search, searchKeys])
|
}, [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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center gap-2 px-5 py-3 border-b">
|
<DataPageToolbar
|
||||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
|
search={search}
|
||||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
onSearchChange={setSearch}
|
||||||
<input
|
searchPlaceholder={searchPlaceholder}
|
||||||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
countLabel={`${displayCount} записей`}
|
||||||
placeholder={searchPlaceholder}
|
/>
|
||||||
value={search}
|
<DataGrid
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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>
|
tableLayout={{ rowBorder: true, headerBackground: true }}
|
||||||
</div>
|
>
|
||||||
<div className="overflow-x-auto">
|
<DataGridContainer border={false}>
|
||||||
<table className="w-full text-sm">
|
<DataGridTable />
|
||||||
<thead>
|
</DataGridContainer>
|
||||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
{filteredData.length > 0 && <DataGridPagination className="px-5 pb-3" />}
|
||||||
{columns.map((col) => (
|
</DataGrid>
|
||||||
<th key={col.key} className="text-left font-medium px-5 py-3">
|
|
||||||
{col.label}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{filtered.map((row) => (
|
|
||||||
<tr key={row.id} className="hover:bg-muted/40 transition-colors">
|
|
||||||
{columns.map((col) => (
|
|
||||||
<td key={col.key} className="px-5 py-3">
|
|
||||||
{col.render(row)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
} from "@/components/ui/empty"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon?: React.ReactNode
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
action?: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<Empty className={cn("border-0 py-16", className)}>
|
||||||
|
<EmptyHeader>
|
||||||
|
{icon && <EmptyMedia variant="icon">{icon}</EmptyMedia>}
|
||||||
|
<EmptyTitle>{title}</EmptyTitle>
|
||||||
|
{description && <EmptyDescription>{description}</EmptyDescription>}
|
||||||
|
</EmptyHeader>
|
||||||
|
{action && <EmptyContent>{action}</EmptyContent>}
|
||||||
|
</Empty>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { EmptyState, type EmptyStateProps }
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react"
|
||||||
|
import { UploadIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useFileUpload } from "@/hooks/use-file-upload"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface FileImportDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
accept?: string
|
||||||
|
multiple?: boolean
|
||||||
|
onImport: (files: File[]) => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileImportDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
accept = "*",
|
||||||
|
multiple = false,
|
||||||
|
onImport,
|
||||||
|
}: FileImportDialogProps) {
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
|
||||||
|
const [{ files, isDragging, errors }, actions] = useFileUpload({
|
||||||
|
accept,
|
||||||
|
multiple,
|
||||||
|
maxFiles: multiple ? 10 : 1,
|
||||||
|
onError: (errs) => {
|
||||||
|
errs.forEach((e) => toast.error(e))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleImport = useCallback(async () => {
|
||||||
|
const rawFiles = files
|
||||||
|
.map((f) => (f.file instanceof File ? f.file : null))
|
||||||
|
.filter((f): f is File => f != null)
|
||||||
|
if (rawFiles.length === 0) {
|
||||||
|
toast.error("Выберите файл для импорта")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setImporting(true)
|
||||||
|
try {
|
||||||
|
await onImport(rawFiles)
|
||||||
|
actions.clearFiles()
|
||||||
|
onOpenChange(false)
|
||||||
|
toast.success("Импорт выполнен")
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Ошибка импорта")
|
||||||
|
} finally {
|
||||||
|
setImporting(false)
|
||||||
|
}
|
||||||
|
}, [actions, files, onImport, onOpenChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
{description && <DialogDescription>{description}</DialogDescription>}
|
||||||
|
</DialogHeader>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center transition-colors",
|
||||||
|
isDragging ? "border-primary bg-primary/5" : "border-border",
|
||||||
|
)}
|
||||||
|
onDragEnter={actions.handleDragEnter}
|
||||||
|
onDragLeave={actions.handleDragLeave}
|
||||||
|
onDragOver={actions.handleDragOver}
|
||||||
|
onDrop={actions.handleDrop}
|
||||||
|
>
|
||||||
|
<UploadIcon className="size-8 text-muted-foreground/40" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Перетащите файл сюда или выберите на диске
|
||||||
|
</p>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={actions.openFileDialog}>
|
||||||
|
Выбрать файл
|
||||||
|
</Button>
|
||||||
|
<input {...actions.getInputProps()} className="sr-only" />
|
||||||
|
{files.length > 0 && (
|
||||||
|
<ul className="w-full text-left text-sm">
|
||||||
|
{files.map((f) => (
|
||||||
|
<li key={f.id} className="truncate font-mono text-xs">
|
||||||
|
{f.file instanceof File ? f.file.name : f.file.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{errors.map((e) => (
|
||||||
|
<p key={e} className="text-xs text-destructive">{e}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={handleImport} disabled={importing || files.length === 0}>
|
||||||
|
{importing ? "Импорт…" : "Импортировать"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { FileImportDialog, type FileImportDialogProps }
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldDescription,
|
||||||
|
FieldLabel,
|
||||||
|
} from "@/components/ui/field"
|
||||||
|
|
||||||
|
interface FormFieldProps {
|
||||||
|
label: string
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
required?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormField({ label, hint, error, required, children }: FormFieldProps) {
|
||||||
|
return (
|
||||||
|
<Field data-invalid={!!error}>
|
||||||
|
<FieldLabel>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-destructive ml-0.5">*</span>}
|
||||||
|
</FieldLabel>
|
||||||
|
{children}
|
||||||
|
{hint && !error && <FieldDescription>{hint}</FieldDescription>}
|
||||||
|
{error && <FieldDescription className="text-destructive">{error}</FieldDescription>}
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { FormField, type FormFieldProps }
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface FormToggleProps {
|
||||||
|
checked: boolean
|
||||||
|
onChange: (value: boolean) => void
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormToggle({ checked, onChange, disabled, className }: FormToggleProps) {
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(className)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { FormToggle, type FormToggleProps }
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { FormField, type FormFieldProps } from "./form-field"
|
||||||
|
export { FormToggle, type FormToggleProps } from "./form-toggle"
|
||||||
|
export { SectionTitle, type SectionTitleProps } from "./section-title"
|
||||||
|
export { SegmentedControl, type SegmentedControlProps } from "./segmented-control"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface SectionTitleProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
icon?: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTitle({ children, icon, className }: SectionTitleProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center gap-2 py-0.5", className)}>
|
||||||
|
{icon}
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-px bg-border" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SectionTitle, type SectionTitleProps }
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface SegmentedControlProps<T extends string> {
|
||||||
|
value: T
|
||||||
|
onChange: (value: T) => void
|
||||||
|
options: { value: T; label: string; count?: number }[]
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function SegmentedControl<T extends string>({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
className,
|
||||||
|
}: SegmentedControlProps<T>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(option.value)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors",
|
||||||
|
value === option.value
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
{option.count != null && (
|
||||||
|
<span className="text-xs tabular-nums opacity-60">{option.count}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SegmentedControl, type SegmentedControlProps }
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
[
|
||||||
|
"relative w-full text-sm border has-[>svg]:grid-cols-[calc(var(--spacing)*3)_1fr] grid-cols-[0_1fr] grid gap-y-0.5 items-center [&>svg:not([class*=size-])]:size-4",
|
||||||
|
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_[data-slot=alert-action]]:sm:row-end-3",
|
||||||
|
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:items-start",
|
||||||
|
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_svg]:translate-y-0.5",
|
||||||
|
"rounded-lg",
|
||||||
|
"px-3",
|
||||||
|
"py-2.5",
|
||||||
|
"has-[>svg]:gap-x-2.5",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"border-destructive/30 bg-destructive/4 [&>svg]:text-destructive",
|
||||||
|
info: "border-info/30 bg-info/4 [&>svg]:text-info",
|
||||||
|
success: "border-success/30 bg-success/4 [&>svg]:text-success",
|
||||||
|
warning: "border-warning/30 bg-warning/4 [&>svg]:text-warning",
|
||||||
|
invert:
|
||||||
|
"border-invert bg-invert text-invert-foreground [&_[data-slot=alert-description]]:text-invert-foreground/70",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-action"
|
||||||
|
className={cn(
|
||||||
|
"flex gap-1.5 max-sm:col-start-2 max-sm:mt-2 max-sm:justify-start sm:col-start-3 sm:row-start-1 sm:justify-end sm:self-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { XIcon, ChevronsUpDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const inputVariants = cva(
|
||||||
|
"outline-none flex w-full text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [[readonly]]:bg-muted/80 [[readonly]]:cursor-not-allowed border border-input focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg bg-transparent dark:bg-input/30 text-sm transition-colors focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-3",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: "h-7 px-2 [&~[data-slot=autocomplete-clear]]:end-1.5 [&~[data-slot=autocomplete-trigger]]:end-1.5",
|
||||||
|
default:
|
||||||
|
"h-8 px-2.5 [&~[data-slot=autocomplete-clear]]:end-1.75 [&~[data-slot=autocomplete-trigger]]:end-1.75",
|
||||||
|
lg: "h-9 px-2.5 [&~[data-slot=autocomplete-clear]]:end-2 [&~[data-slot=autocomplete-trigger]]:end-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Autocomplete = AutocompletePrimitive.Root
|
||||||
|
|
||||||
|
function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteInput({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
showClear = false,
|
||||||
|
showTrigger = false,
|
||||||
|
...props
|
||||||
|
}: Omit<AutocompletePrimitive.Input.Props, "size"> &
|
||||||
|
VariantProps<typeof inputVariants> & {
|
||||||
|
showClear?: boolean
|
||||||
|
showTrigger?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
<AutocompletePrimitive.Input
|
||||||
|
data-slot="autocomplete-input"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(inputVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{showTrigger && <AutocompleteTrigger />}
|
||||||
|
{showClear && <AutocompleteClear />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteStatus({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AutocompletePrimitive.Status.Props) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Status
|
||||||
|
data-slot="autocomplete-status"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground px-2 py-1.5 text-sm empty:m-0 empty:p-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Portal data-slot="autocomplete-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteBackdrop({
|
||||||
|
...props
|
||||||
|
}: AutocompletePrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Backdrop
|
||||||
|
data-slot="autocomplete-backdrop"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompletePositioner({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AutocompletePrimitive.Positioner.Props) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Positioner
|
||||||
|
data-slot="autocomplete-positioner"
|
||||||
|
className={cn("z-50 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteList({
|
||||||
|
className,
|
||||||
|
scrollAreaClassName,
|
||||||
|
...props
|
||||||
|
}: AutocompletePrimitive.List.Props & {
|
||||||
|
scrollAreaClassName?: string
|
||||||
|
scrollFade?: boolean
|
||||||
|
scrollbarGutter?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ScrollArea
|
||||||
|
className={cn(
|
||||||
|
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain",
|
||||||
|
scrollAreaClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AutocompletePrimitive.List
|
||||||
|
data-slot="autocomplete-list"
|
||||||
|
className={cn(
|
||||||
|
"not-empty:px-1 not-empty:py-1 not-empty:scroll-py-1 in-data-has-overflow-y:me-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ScrollArea>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteCollection({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AutocompletePrimitive.Collection>) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Collection
|
||||||
|
data-slot="autocomplete-collection"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteRow({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AutocompletePrimitive.Row>) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Row
|
||||||
|
data-slot="autocomplete-row"
|
||||||
|
className={cn("flex items-center gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AutocompletePrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Item
|
||||||
|
data-slot="autocomplete-item"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5 rounded-md px-1.5 py-1 text-sm data-highlighted:before:rounded-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutocompleteContentProps extends React.ComponentProps<
|
||||||
|
typeof AutocompletePrimitive.Popup
|
||||||
|
> {
|
||||||
|
align?: AutocompletePrimitive.Positioner.Props["align"]
|
||||||
|
sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"]
|
||||||
|
alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"]
|
||||||
|
side?: AutocompletePrimitive.Positioner.Props["side"]
|
||||||
|
anchor?: AutocompletePrimitive.Positioner.Props["anchor"]
|
||||||
|
showBackdrop?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showBackdrop = false,
|
||||||
|
align = "start",
|
||||||
|
sideOffset = 4,
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
anchor,
|
||||||
|
...props
|
||||||
|
}: AutocompleteContentProps) {
|
||||||
|
return (
|
||||||
|
<AutocompletePortal>
|
||||||
|
{showBackdrop && <AutocompleteBackdrop />}
|
||||||
|
<AutocompletePositioner
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
anchor={anchor}
|
||||||
|
>
|
||||||
|
<div className="relative flex max-h-full">
|
||||||
|
<AutocompletePrimitive.Popup
|
||||||
|
data-slot="autocomplete-popup"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground rounded-lg shadow-md ring-foreground/10 flex max-h-[min(var(--available-height),24rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) scroll-pt-2 scroll-pb-2 flex-col overscroll-contain py-0.5 ring-1 transition-[scale,opacity] has-data-starting-style:scale-98 has-data-starting-style:opacity-0 has-data-[side=none]:scale-100 has-data-[side=none]:transition-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AutocompletePrimitive.Popup>
|
||||||
|
</div>
|
||||||
|
</AutocompletePositioner>
|
||||||
|
</AutocompletePortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AutocompletePrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Group data-slot="autocomplete-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteGroupLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AutocompletePrimitive.GroupLabel>) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.GroupLabel
|
||||||
|
data-slot="autocomplete-group-label"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground px-1.5 py-1 text-xs font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteEmpty({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AutocompletePrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Empty
|
||||||
|
data-slot="autocomplete-empty"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground px-2 py-1.5 text-sm text-center empty:m-0 empty:p-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteClear({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AutocompletePrimitive.Clear>) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Clear
|
||||||
|
data-slot="autocomplete-clear"
|
||||||
|
className={cn(
|
||||||
|
"ring-offset-background focus:ring-ring absolute top-1/2 -translate-y-1/2 cursor-pointer opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-disabled:pointer-events-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
</AutocompletePrimitive.Clear>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AutocompletePrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Trigger
|
||||||
|
data-slot="autocomplete-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus:ring-ring ring-offset-background absolute top-1/2 -translate-y-1/2 cursor-pointer focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none has-[+[data-slot=autocomplete-clear]]:hidden data-disabled:pointer-events-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronsUpDownIcon className="size-4 opacity-70" />
|
||||||
|
</AutocompletePrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteArrow({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AutocompletePrimitive.Arrow>) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Arrow data-slot="autocomplete-arrow" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutocompleteSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AutocompletePrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<AutocompletePrimitive.Separator
|
||||||
|
data-slot="autocomplete-separator"
|
||||||
|
className={cn(
|
||||||
|
"bg-border my-1.5 h-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Autocomplete,
|
||||||
|
AutocompleteValue,
|
||||||
|
AutocompleteTrigger,
|
||||||
|
AutocompleteInput,
|
||||||
|
AutocompleteStatus,
|
||||||
|
AutocompletePortal,
|
||||||
|
AutocompleteBackdrop,
|
||||||
|
AutocompletePositioner,
|
||||||
|
AutocompleteContent,
|
||||||
|
AutocompleteList,
|
||||||
|
AutocompleteCollection,
|
||||||
|
AutocompleteRow,
|
||||||
|
AutocompleteItem,
|
||||||
|
AutocompleteGroup,
|
||||||
|
AutocompleteGroupLabel,
|
||||||
|
AutocompleteEmpty,
|
||||||
|
AutocompleteClear,
|
||||||
|
AutocompleteArrow,
|
||||||
|
AutocompleteSeparator,
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground",
|
||||||
|
outline: "border-border bg-transparent dark:bg-input/32",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground",
|
||||||
|
info: "bg-info text-white",
|
||||||
|
success: "bg-success text-white",
|
||||||
|
warning: "bg-warning text-white",
|
||||||
|
destructive: "bg-destructive text-white",
|
||||||
|
focus: "bg-focus text-focus-foreground",
|
||||||
|
invert: "bg-invert text-invert-foreground",
|
||||||
|
"primary-light":
|
||||||
|
"bg-primary/10 border-none text-primary dark:bg-primary/20",
|
||||||
|
"warning-light":
|
||||||
|
"bg-warning/10 border-none text-warning-foreground dark:bg-warning/20",
|
||||||
|
"success-light":
|
||||||
|
"bg-success/10 border-none text-success-foreground dark:bg-success/20",
|
||||||
|
"info-light":
|
||||||
|
"bg-info/10 border-none text-info-foreground dark:bg-info/20",
|
||||||
|
"destructive-light":
|
||||||
|
"bg-destructive/10 border-none text-destructive-foreground dark:bg-destructive/20",
|
||||||
|
"invert-light":
|
||||||
|
"bg-invert/10 border-none text-foreground dark:bg-invert/20",
|
||||||
|
"focus-light":
|
||||||
|
"bg-focus/10 border-none text-focus-foreground dark:bg-focus/20",
|
||||||
|
"primary-outline":
|
||||||
|
"bg-background border-border text-primary dark:bg-input/30",
|
||||||
|
"warning-outline":
|
||||||
|
"bg-background border-border text-warning-foreground dark:bg-input/30",
|
||||||
|
"success-outline":
|
||||||
|
"bg-background border-border text-success-foreground dark:bg-input/30",
|
||||||
|
"info-outline":
|
||||||
|
"bg-background border-border text-info-foreground dark:bg-input/30",
|
||||||
|
"destructive-outline":
|
||||||
|
"bg-background border-border text-destructive-foreground dark:bg-input/30",
|
||||||
|
"invert-outline":
|
||||||
|
"bg-background border-border text-invert-foreground dark:bg-input/30",
|
||||||
|
"focus-outline":
|
||||||
|
"bg-background border-border text-focus-foreground dark:bg-input/30",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
xs: "px-1 py-0.25 text-[0.6rem] leading-none h-4 min-w-4 gap-1",
|
||||||
|
sm: "px-1 py-0.25 text-[0.625rem] leading-none h-4.5 min-w-4.5 gap-1",
|
||||||
|
default: "px-1.25 py-0.5 text-xs h-5 min-w-5 gap-1",
|
||||||
|
lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1",
|
||||||
|
xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5",
|
||||||
|
},
|
||||||
|
/** `default`: per-theme radius. `full`: max radius per theme (Lyra stays `rounded-none`). */
|
||||||
|
radius: {
|
||||||
|
default:
|
||||||
|
"rounded-sm",
|
||||||
|
full: "rounded-full",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
radius: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
interface BadgeProps extends useRender.ComponentProps<"span"> {
|
||||||
|
variant?: VariantProps<typeof badgeVariants>["variant"]
|
||||||
|
size?: VariantProps<typeof badgeVariants>["size"]
|
||||||
|
radius?: VariantProps<typeof badgeVariants>["radius"]
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
radius,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: BadgeProps) {
|
||||||
|
const defaultProps = {
|
||||||
|
"data-slot": "badge",
|
||||||
|
className: cn(badgeVariants({ variant, size, radius, className })),
|
||||||
|
}
|
||||||
|
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
render,
|
||||||
|
props: mergeProps<"span">(defaultProps, props),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants, type BadgeProps }
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { Column } from "@tanstack/react-table"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { CirclePlusIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
interface DataGridColumnFilterProps<TData, TValue> {
|
||||||
|
column?: Column<TData, TValue>
|
||||||
|
title?: string
|
||||||
|
options: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
icon?: React.ComponentType<{ className?: string }>
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataGridColumnFilter<TData, TValue>({
|
||||||
|
column,
|
||||||
|
title,
|
||||||
|
options,
|
||||||
|
}: DataGridColumnFilterProps<TData, TValue>) {
|
||||||
|
const facets = column?.getFacetedUniqueValues()
|
||||||
|
const selectedValues = new Set(column?.getFilterValue() as string[])
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
|
||||||
|
const filteredOptions = useMemo(() => {
|
||||||
|
if (!searchQuery) return options
|
||||||
|
return options.filter((option) =>
|
||||||
|
option.label.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
}, [options, searchQuery])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<CirclePlusIcon className="size-4" />
|
||||||
|
{title}
|
||||||
|
{selectedValues?.size > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="rounded-sm px-1 font-normal lg:hidden"
|
||||||
|
>
|
||||||
|
{selectedValues.size}
|
||||||
|
</Badge>
|
||||||
|
<div className="hidden space-x-1 lg:flex">
|
||||||
|
{selectedValues.size > 2 ? (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="rounded-sm px-1 font-normal"
|
||||||
|
>
|
||||||
|
{selectedValues.size} selected
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
options
|
||||||
|
.filter((option) => selectedValues.has(option.value))
|
||||||
|
.map((option) => (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
key={option.value}
|
||||||
|
className="rounded-sm px-1 font-normal"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PopoverContent className="w-[200px] p-0" align="start">
|
||||||
|
<div className="p-2">
|
||||||
|
<Input
|
||||||
|
placeholder={title}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[300px] overflow-y-auto">
|
||||||
|
{filteredOptions.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground py-6 text-center text-sm">
|
||||||
|
No results found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-1">
|
||||||
|
{filteredOptions.map((option) => {
|
||||||
|
const isSelected = selectedValues.has(option.value)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => {
|
||||||
|
if (isSelected) {
|
||||||
|
selectedValues.delete(option.value)
|
||||||
|
} else {
|
||||||
|
selectedValues.add(option.value)
|
||||||
|
}
|
||||||
|
const filterValues = Array.from(selectedValues)
|
||||||
|
column?.setFilterValue(
|
||||||
|
filterValues.length ? filterValues : undefined
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-primary me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "opacity-50 [&_svg]:invisible"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
{option.icon && (
|
||||||
|
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{facets?.get(option.value) && (
|
||||||
|
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
||||||
|
{facets.get(option.value)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedValues.size > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="bg-border -mx-1 my-1 h-px" />
|
||||||
|
<div className="p-1">
|
||||||
|
<div
|
||||||
|
onClick={() => column?.setFilterValue(undefined)}
|
||||||
|
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DataGridColumnFilter, type DataGridColumnFilterProps }
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
|
||||||
|
import {
|
||||||
|
getColumnHeaderLabel,
|
||||||
|
useDataGrid,
|
||||||
|
} from "@/components/reui/data-grid/data-grid"
|
||||||
|
import { Column } from "@tanstack/react-table"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
|
||||||
|
|
||||||
|
interface DataGridColumnHeaderProps<
|
||||||
|
TData,
|
||||||
|
TValue,
|
||||||
|
> extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
column: Column<TData, TValue>
|
||||||
|
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
|
||||||
|
title?: string
|
||||||
|
icon?: ReactNode
|
||||||
|
pinnable?: boolean
|
||||||
|
filter?: ReactNode
|
||||||
|
visibility?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataGridColumnHeaderInner<TData, TValue>({
|
||||||
|
column,
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
className,
|
||||||
|
filter,
|
||||||
|
visibility = false,
|
||||||
|
}: DataGridColumnHeaderProps<TData, TValue>) {
|
||||||
|
const { isLoading, table, props, recordCount } = useDataGrid()
|
||||||
|
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
||||||
|
|
||||||
|
const columnOrder = table.getState().columnOrder
|
||||||
|
const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility)
|
||||||
|
const isSorted = column.getIsSorted()
|
||||||
|
const isPinned = column.getIsPinned()
|
||||||
|
const canSort = column.getCanSort()
|
||||||
|
const canPin = column.getCanPin()
|
||||||
|
const canResize = column.getCanResize()
|
||||||
|
|
||||||
|
const columnIndex = columnOrder.indexOf(column.id)
|
||||||
|
const canMoveLeft = columnIndex > 0
|
||||||
|
const canMoveRight = columnIndex < columnOrder.length - 1
|
||||||
|
|
||||||
|
const handleSort = () => {
|
||||||
|
if (isSorted === "asc") {
|
||||||
|
column.toggleSorting(true)
|
||||||
|
} else if (isSorted === "desc") {
|
||||||
|
column.clearSorting()
|
||||||
|
} else {
|
||||||
|
column.toggleSorting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerLabelClassName = cn(
|
||||||
|
"text-secondary-foreground/80 inline-flex h-full items-center gap-1.5 font-normal [&_svg]:opacity-60 text-[0.8125rem] leading-[calc(1.125/0.8125)] [&_svg]:size-3.5",
|
||||||
|
className
|
||||||
|
)
|
||||||
|
|
||||||
|
const headerButtonClassName = cn(
|
||||||
|
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg",
|
||||||
|
className
|
||||||
|
)
|
||||||
|
|
||||||
|
const sortIcon =
|
||||||
|
canSort &&
|
||||||
|
(isSorted === "desc" ? (
|
||||||
|
<ArrowDownIcon className="size-3.25" />
|
||||||
|
) : isSorted === "asc" ? (
|
||||||
|
<ArrowUpIcon className="size-3.25" />
|
||||||
|
) : (
|
||||||
|
<ChevronsUpDownIcon className="mt-px size-3.25" />
|
||||||
|
))
|
||||||
|
|
||||||
|
const hasControls =
|
||||||
|
props.tableLayout?.columnsMovable ||
|
||||||
|
(props.tableLayout?.columnsVisibility && visibility) ||
|
||||||
|
(props.tableLayout?.columnsPinnable && canPin) ||
|
||||||
|
filter
|
||||||
|
|
||||||
|
const menuItems = useMemo(() => {
|
||||||
|
const items: ReactNode[] = []
|
||||||
|
let hasPreviousSection = false
|
||||||
|
|
||||||
|
// Filter section
|
||||||
|
if (filter) {
|
||||||
|
items.push(
|
||||||
|
<DropdownMenuGroup key="group-filter">
|
||||||
|
<DropdownMenuLabel key="filter">{filter}</DropdownMenuLabel>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
)
|
||||||
|
hasPreviousSection = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort section
|
||||||
|
if (canSort) {
|
||||||
|
if (hasPreviousSection) {
|
||||||
|
items.push(<DropdownMenuSeparator key="sep-sort" />)
|
||||||
|
}
|
||||||
|
items.push(
|
||||||
|
<DropdownMenuItem
|
||||||
|
key="sort-asc"
|
||||||
|
onClick={() => {
|
||||||
|
if (isSorted === "asc") {
|
||||||
|
column.clearSorting()
|
||||||
|
} else {
|
||||||
|
column.toggleSorting(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!canSort}
|
||||||
|
>
|
||||||
|
<ArrowUpIcon className="size-3.5!" />
|
||||||
|
<span className="grow">Asc</span>
|
||||||
|
{isSorted === "asc" && (
|
||||||
|
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>,
|
||||||
|
<DropdownMenuItem
|
||||||
|
key="sort-desc"
|
||||||
|
onClick={() => {
|
||||||
|
if (isSorted === "desc") {
|
||||||
|
column.clearSorting()
|
||||||
|
} else {
|
||||||
|
column.toggleSorting(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!canSort}
|
||||||
|
>
|
||||||
|
<ArrowDownIcon className="size-3.5!" />
|
||||||
|
<span className="grow">Desc</span>
|
||||||
|
{isSorted === "desc" && (
|
||||||
|
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
hasPreviousSection = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pin section
|
||||||
|
if (props.tableLayout?.columnsPinnable && canPin) {
|
||||||
|
if (hasPreviousSection) {
|
||||||
|
items.push(<DropdownMenuSeparator key="sep-pin" />)
|
||||||
|
}
|
||||||
|
items.push(
|
||||||
|
<DropdownMenuItem
|
||||||
|
key="pin-left"
|
||||||
|
onClick={() => column.pin(isPinned === "left" ? false : "left")}
|
||||||
|
>
|
||||||
|
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||||
|
<span className="grow">Pin to left</span>
|
||||||
|
{isPinned === "left" && (
|
||||||
|
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>,
|
||||||
|
<DropdownMenuItem
|
||||||
|
key="pin-right"
|
||||||
|
onClick={() => column.pin(isPinned === "right" ? false : "right")}
|
||||||
|
>
|
||||||
|
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||||
|
<span className="grow">Pin to right</span>
|
||||||
|
{isPinned === "right" && (
|
||||||
|
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
hasPreviousSection = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move section
|
||||||
|
if (props.tableLayout?.columnsMovable) {
|
||||||
|
if (hasPreviousSection) {
|
||||||
|
items.push(<DropdownMenuSeparator key="sep-move" />)
|
||||||
|
}
|
||||||
|
items.push(
|
||||||
|
<DropdownMenuItem
|
||||||
|
key="move-left"
|
||||||
|
onClick={() => {
|
||||||
|
if (columnIndex > 0) {
|
||||||
|
const newOrder = [...columnOrder]
|
||||||
|
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
||||||
|
newOrder.splice(columnIndex - 1, 0, movedColumn)
|
||||||
|
table.setColumnOrder(newOrder)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!canMoveLeft || isPinned !== false}
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
|
||||||
|
<span>Move to Left</span>
|
||||||
|
</DropdownMenuItem>,
|
||||||
|
<DropdownMenuItem
|
||||||
|
key="move-right"
|
||||||
|
onClick={() => {
|
||||||
|
if (columnIndex < columnOrder.length - 1) {
|
||||||
|
const newOrder = [...columnOrder]
|
||||||
|
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
||||||
|
newOrder.splice(columnIndex + 1, 0, movedColumn)
|
||||||
|
table.setColumnOrder(newOrder)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!canMoveRight || isPinned !== false}
|
||||||
|
>
|
||||||
|
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
|
||||||
|
<span>Move to Right</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
hasPreviousSection = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visibility section
|
||||||
|
if (props.tableLayout?.columnsVisibility && visibility) {
|
||||||
|
if (hasPreviousSection) {
|
||||||
|
items.push(<DropdownMenuSeparator key="sep-visibility" />)
|
||||||
|
}
|
||||||
|
items.push(
|
||||||
|
<DropdownMenuSub key="visibility">
|
||||||
|
<DropdownMenuSubTrigger>
|
||||||
|
<Settings2Icon className="size-3.5!" />
|
||||||
|
<span>Columns</span>
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent side="right">
|
||||||
|
{table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter((col) => col.getCanHide())
|
||||||
|
.map((col) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={col.id}
|
||||||
|
checked={col.getIsVisible()}
|
||||||
|
onSelect={(event) => event.preventDefault()}
|
||||||
|
onCheckedChange={(value) => col.toggleVisibility(!!value)}
|
||||||
|
className="capitalize"
|
||||||
|
>
|
||||||
|
{getColumnHeaderLabel(col)}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [
|
||||||
|
filter,
|
||||||
|
canSort,
|
||||||
|
isSorted,
|
||||||
|
column,
|
||||||
|
props.tableLayout?.columnsPinnable,
|
||||||
|
props.tableLayout?.columnsMovable,
|
||||||
|
props.tableLayout?.columnsVisibility,
|
||||||
|
canPin,
|
||||||
|
isPinned,
|
||||||
|
canMoveLeft,
|
||||||
|
canMoveRight,
|
||||||
|
visibility,
|
||||||
|
table,
|
||||||
|
columnIndex,
|
||||||
|
columnOrder,
|
||||||
|
columnVisibilityKey, // Needed to update checkbox states when visibility changes
|
||||||
|
])
|
||||||
|
|
||||||
|
if (hasControls) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-between gap-1.5">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={headerButtonClassName}
|
||||||
|
disabled={isLoading || recordCount === 0}
|
||||||
|
>
|
||||||
|
{icon && icon}
|
||||||
|
{resolvedTitle}
|
||||||
|
{sortIcon}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent className="w-40" align="start">
|
||||||
|
{menuItems}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="-me-1 size-7 rounded-md"
|
||||||
|
onClick={() => column.pin(false)}
|
||||||
|
aria-label={`Unpin ${resolvedTitle} column`}
|
||||||
|
title={`Unpin ${resolvedTitle} column`}
|
||||||
|
>
|
||||||
|
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={headerButtonClassName}
|
||||||
|
disabled={isLoading || recordCount === 0}
|
||||||
|
onClick={handleSort}
|
||||||
|
>
|
||||||
|
{icon && icon}
|
||||||
|
{resolvedTitle}
|
||||||
|
{sortIcon}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={headerLabelClassName}>
|
||||||
|
{icon && icon}
|
||||||
|
{resolvedTitle}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DataGridColumnHeader = memo(
|
||||||
|
DataGridColumnHeaderInner
|
||||||
|
) as typeof DataGridColumnHeaderInner
|
||||||
|
|
||||||
|
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ReactElement } from "react"
|
||||||
|
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
|
||||||
|
import { Table } from "@tanstack/react-table"
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
|
||||||
|
function DataGridColumnVisibility<TData>({
|
||||||
|
table,
|
||||||
|
trigger,
|
||||||
|
}: {
|
||||||
|
table: Table<TData>
|
||||||
|
trigger: ReactElement<Record<string, unknown>>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger render={trigger} />
|
||||||
|
<DropdownMenuContent align="end" className="min-w-[150px]">
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuLabel className="font-medium">
|
||||||
|
Toggle Columns
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter((column) => column.getCanHide())
|
||||||
|
.map((column) => {
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={column.id}
|
||||||
|
className="capitalize"
|
||||||
|
checked={column.getIsVisible()}
|
||||||
|
onSelect={(event) => event.preventDefault()}
|
||||||
|
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||||
|
>
|
||||||
|
{getColumnHeaderLabel(column)}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DataGridColumnVisibility }
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { ReactNode } from "react"
|
||||||
|
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
||||||
|
|
||||||
|
interface DataGridPaginationProps {
|
||||||
|
sizes?: number[]
|
||||||
|
sizesInfo?: string
|
||||||
|
sizesLabel?: string
|
||||||
|
sizesDescription?: string
|
||||||
|
sizesSkeleton?: ReactNode
|
||||||
|
more?: boolean
|
||||||
|
moreLimit?: number
|
||||||
|
info?: string
|
||||||
|
infoSkeleton?: ReactNode
|
||||||
|
className?: string
|
||||||
|
rowsPerPageLabel?: string
|
||||||
|
previousPageLabel?: string
|
||||||
|
nextPageLabel?: string
|
||||||
|
ellipsisText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||||
|
const { table, recordCount, isLoading } = useDataGrid()
|
||||||
|
|
||||||
|
const defaultProps: Partial<DataGridPaginationProps> = {
|
||||||
|
sizes: [5, 10, 25, 50, 100],
|
||||||
|
sizesLabel: "Show",
|
||||||
|
sizesDescription: "per page",
|
||||||
|
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
||||||
|
moreLimit: 5,
|
||||||
|
more: false,
|
||||||
|
info: "{from} - {to} of {count}",
|
||||||
|
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
||||||
|
rowsPerPageLabel: "Rows per page",
|
||||||
|
previousPageLabel: "Go to previous page",
|
||||||
|
nextPageLabel: "Go to next page",
|
||||||
|
ellipsisText: "...",
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
|
||||||
|
|
||||||
|
const btnBaseClasses = "size-7 p-0 text-sm"
|
||||||
|
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
|
||||||
|
const pageIndex = table.getState().pagination.pageIndex
|
||||||
|
const pageSize = table.getState().pagination.pageSize
|
||||||
|
const from = pageIndex * pageSize + 1
|
||||||
|
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
|
||||||
|
const pageCount = table.getPageCount()
|
||||||
|
|
||||||
|
// Replace placeholders in paginationInfo
|
||||||
|
const paginationInfo = mergedProps?.info
|
||||||
|
? mergedProps.info
|
||||||
|
.replace("{from}", from.toString())
|
||||||
|
.replace("{to}", to.toString())
|
||||||
|
.replace("{count}", recordCount.toString())
|
||||||
|
: `${from} - ${to} of ${recordCount}`
|
||||||
|
|
||||||
|
// Pagination limit logic
|
||||||
|
const paginationMoreLimit = mergedProps?.moreLimit || 5
|
||||||
|
|
||||||
|
// Determine the start and end of the pagination group
|
||||||
|
const currentGroupStart =
|
||||||
|
Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit
|
||||||
|
const currentGroupEnd = Math.min(
|
||||||
|
currentGroupStart + paginationMoreLimit,
|
||||||
|
pageCount
|
||||||
|
)
|
||||||
|
|
||||||
|
// Render page buttons based on the current group
|
||||||
|
const renderPageButtons = () => {
|
||||||
|
const buttons = []
|
||||||
|
for (let i = currentGroupStart; i < currentGroupEnd; i++) {
|
||||||
|
buttons.push(
|
||||||
|
<Button
|
||||||
|
key={i}
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(btnBaseClasses, "text-muted-foreground", {
|
||||||
|
"bg-accent text-accent-foreground": pageIndex === i,
|
||||||
|
})}
|
||||||
|
onClick={() => {
|
||||||
|
if (pageIndex !== i) {
|
||||||
|
table.setPageIndex(i)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return buttons
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a "previous" ellipsis button if there are previous pages to show
|
||||||
|
const renderEllipsisPrevButton = () => {
|
||||||
|
if (currentGroupStart > 0) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
className={btnBaseClasses}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => table.setPageIndex(currentGroupStart - 1)}
|
||||||
|
>
|
||||||
|
{mergedProps.ellipsisText}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a "next" ellipsis button if there are more pages to show after the current group
|
||||||
|
const renderEllipsisNextButton = () => {
|
||||||
|
if (currentGroupEnd < pageCount) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={btnBaseClasses}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => table.setPageIndex(currentGroupEnd)}
|
||||||
|
>
|
||||||
|
{mergedProps.ellipsisText}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="data-grid-pagination"
|
||||||
|
className={cn(
|
||||||
|
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
|
||||||
|
mergedProps?.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
|
||||||
|
{isLoading ? (
|
||||||
|
mergedProps?.sizesSkeleton
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{mergedProps.rowsPerPageLabel}
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={`${pageSize}`}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newPageSize = Number(value)
|
||||||
|
table.setPageSize(newPageSize)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-14" size="sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side="top" className="min-w-18">
|
||||||
|
{mergedProps?.sizes?.map((size: number) => (
|
||||||
|
<SelectItem key={size} value={`${size}`}>
|
||||||
|
{size}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
|
||||||
|
{isLoading ? (
|
||||||
|
mergedProps?.infoSkeleton
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-muted-foreground text-sm order-2 text-nowrap sm:order-1">
|
||||||
|
{paginationInfo}
|
||||||
|
</div>
|
||||||
|
{pageCount > 1 && (
|
||||||
|
<div className="order-1 flex items-center space-x-1 sm:order-2">
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
className={btnArrowClasses}
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">
|
||||||
|
{mergedProps.previousPageLabel}
|
||||||
|
</span>
|
||||||
|
<ChevronLeftIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{renderEllipsisPrevButton()}
|
||||||
|
|
||||||
|
{renderPageButtons()}
|
||||||
|
|
||||||
|
{renderEllipsisNextButton()}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
className={btnArrowClasses}
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">{mergedProps.nextPageLabel}</span>
|
||||||
|
<ChevronRightIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DataGridPagination, type DataGridPaginationProps }
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
PointerEvent,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||||
|
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const MIN_THUMB_SIZE = 24
|
||||||
|
const FALLBACK_SCROLLBAR_SIZE = 12
|
||||||
|
|
||||||
|
const INITIAL_METRICS = {
|
||||||
|
hasVerticalOverflow: false,
|
||||||
|
headerHeight: 0,
|
||||||
|
horizontalScrollbarSize: 0,
|
||||||
|
thumbHeight: 0,
|
||||||
|
thumbTop: 0,
|
||||||
|
trackHeight: 0,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
|
||||||
|
|
||||||
|
type ScrollbarMetrics = {
|
||||||
|
hasVerticalOverflow: boolean
|
||||||
|
headerHeight: number
|
||||||
|
horizontalScrollbarSize: number
|
||||||
|
thumbHeight: number
|
||||||
|
thumbTop: number
|
||||||
|
trackHeight: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ObservedElements = {
|
||||||
|
header: HTMLElement | null
|
||||||
|
horizontalScrollbar: HTMLElement | null
|
||||||
|
table: HTMLElement | null
|
||||||
|
tableViewport: HTMLElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataGridScrollAreaProps = Omit<
|
||||||
|
ScrollAreaPrimitive.Root.Props,
|
||||||
|
"children"
|
||||||
|
> & {
|
||||||
|
children: ReactNode
|
||||||
|
orientation?: DataGridScrollAreaOrientation
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) {
|
||||||
|
return (
|
||||||
|
next.hasVerticalOverflow === prev.hasVerticalOverflow &&
|
||||||
|
next.headerHeight === prev.headerHeight &&
|
||||||
|
next.horizontalScrollbarSize === prev.horizontalScrollbarSize &&
|
||||||
|
next.thumbHeight === prev.thumbHeight &&
|
||||||
|
next.thumbTop === prev.thumbTop &&
|
||||||
|
next.trackHeight === prev.trackHeight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) {
|
||||||
|
element.style.setProperty(
|
||||||
|
"--data-grid-scrollbar-header-height",
|
||||||
|
`${metrics.headerHeight}px`
|
||||||
|
)
|
||||||
|
element.style.setProperty(
|
||||||
|
"--data-grid-scrollbar-thumb-height",
|
||||||
|
`${metrics.thumbHeight}px`
|
||||||
|
)
|
||||||
|
element.style.setProperty(
|
||||||
|
"--data-grid-scrollbar-thumb-top",
|
||||||
|
`${metrics.thumbTop}px`
|
||||||
|
)
|
||||||
|
element.style.setProperty(
|
||||||
|
"--data-grid-scrollbar-track-height",
|
||||||
|
`${metrics.trackHeight}px`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataGridScrollArea({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
orientation = "both",
|
||||||
|
...props
|
||||||
|
}: DataGridScrollAreaProps) {
|
||||||
|
const { props: dataGridProps } = useDataGrid()
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const dragRef = useRef<{
|
||||||
|
pointerId: number
|
||||||
|
startScrollTop: number
|
||||||
|
startY: number
|
||||||
|
} | null>(null)
|
||||||
|
const metricsRef = useRef<ScrollbarMetrics>(INITIAL_METRICS)
|
||||||
|
const observedElementsRef = useRef<ObservedElements>({
|
||||||
|
header: null,
|
||||||
|
horizontalScrollbar: null,
|
||||||
|
table: null,
|
||||||
|
tableViewport: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const showHorizontal = orientation !== "vertical"
|
||||||
|
const showVertical = orientation !== "horizontal"
|
||||||
|
const usesCustomVerticalScrollbar =
|
||||||
|
showVertical && !!dataGridProps.tableLayout?.headerSticky
|
||||||
|
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
|
||||||
|
useState(false)
|
||||||
|
|
||||||
|
const clearDragState = useCallback(() => {
|
||||||
|
dragRef.current = null
|
||||||
|
document.body.style.userSelect = ""
|
||||||
|
document.body.style.webkitUserSelect = ""
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const resetMetrics = useCallback(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
|
||||||
|
if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
|
||||||
|
applyMetrics(container, INITIAL_METRICS)
|
||||||
|
metricsRef.current = INITIAL_METRICS
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const syncCustomVerticalScrollbar = useCallback(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
const viewport = viewportRef.current
|
||||||
|
|
||||||
|
if (!container || !viewport || !usesCustomVerticalScrollbar) {
|
||||||
|
resetMetrics()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { header, horizontalScrollbar } = observedElementsRef.current
|
||||||
|
const headerHeight = header?.getBoundingClientRect().height ?? 0
|
||||||
|
const viewportHeight = viewport.clientHeight
|
||||||
|
const viewportWidth = viewport.clientWidth
|
||||||
|
const scrollHeight = viewport.scrollHeight
|
||||||
|
const scrollWidth = viewport.scrollWidth
|
||||||
|
const hasHorizontalOverflow =
|
||||||
|
showHorizontal && scrollWidth > viewportWidth + 0.5
|
||||||
|
const horizontalScrollbarSize = hasHorizontalOverflow
|
||||||
|
? horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE
|
||||||
|
: 0
|
||||||
|
const trackHeight = Math.max(
|
||||||
|
0,
|
||||||
|
viewportHeight - headerHeight - horizontalScrollbarSize
|
||||||
|
)
|
||||||
|
const maxScroll = Math.max(0, scrollHeight - viewportHeight)
|
||||||
|
|
||||||
|
let nextMetrics: ScrollbarMetrics
|
||||||
|
|
||||||
|
if (trackHeight === 0 || maxScroll === 0) {
|
||||||
|
nextMetrics = {
|
||||||
|
hasVerticalOverflow: false,
|
||||||
|
headerHeight,
|
||||||
|
horizontalScrollbarSize,
|
||||||
|
thumbHeight: trackHeight,
|
||||||
|
thumbTop: 0,
|
||||||
|
trackHeight,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const bodyContentHeight = Math.max(
|
||||||
|
trackHeight,
|
||||||
|
scrollHeight - headerHeight
|
||||||
|
)
|
||||||
|
const thumbHeight = clamp(
|
||||||
|
trackHeight * (trackHeight / bodyContentHeight),
|
||||||
|
MIN_THUMB_SIZE,
|
||||||
|
trackHeight
|
||||||
|
)
|
||||||
|
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
||||||
|
const thumbTop =
|
||||||
|
maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0
|
||||||
|
|
||||||
|
nextMetrics = {
|
||||||
|
hasVerticalOverflow: true,
|
||||||
|
headerHeight,
|
||||||
|
horizontalScrollbarSize,
|
||||||
|
thumbHeight,
|
||||||
|
thumbTop,
|
||||||
|
trackHeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
|
||||||
|
applyMetrics(container, nextMetrics)
|
||||||
|
metricsRef.current = nextMetrics
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasCustomVerticalOverflow((prev) =>
|
||||||
|
prev === nextMetrics.hasVerticalOverflow
|
||||||
|
? prev
|
||||||
|
: nextMetrics.hasVerticalOverflow
|
||||||
|
)
|
||||||
|
}, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
const viewport = viewportRef.current
|
||||||
|
|
||||||
|
if (!container || !viewport) return
|
||||||
|
|
||||||
|
if (!usesCustomVerticalScrollbar) {
|
||||||
|
resetMetrics()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
observedElementsRef.current = {
|
||||||
|
header: container.querySelector(
|
||||||
|
'[data-slot="data-grid-table"] thead'
|
||||||
|
) as HTMLElement | null,
|
||||||
|
horizontalScrollbar: container.querySelector(
|
||||||
|
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
|
||||||
|
) as HTMLElement | null,
|
||||||
|
table: container.querySelector(
|
||||||
|
'[data-slot="data-grid-table"]'
|
||||||
|
) as HTMLElement | null,
|
||||||
|
tableViewport: container.querySelector(
|
||||||
|
'[data-slot="data-grid-table-viewport"]'
|
||||||
|
) as HTMLElement | null,
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame = 0
|
||||||
|
|
||||||
|
const scheduleSync = () => {
|
||||||
|
cancelAnimationFrame(frame)
|
||||||
|
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleSync()
|
||||||
|
viewport.addEventListener("scroll", scheduleSync, { passive: true })
|
||||||
|
|
||||||
|
const observer =
|
||||||
|
typeof ResizeObserver === "undefined"
|
||||||
|
? null
|
||||||
|
: new ResizeObserver(scheduleSync)
|
||||||
|
|
||||||
|
observer?.observe(viewport)
|
||||||
|
observedElementsRef.current.header &&
|
||||||
|
observer?.observe(observedElementsRef.current.header)
|
||||||
|
observedElementsRef.current.table &&
|
||||||
|
observer?.observe(observedElementsRef.current.table)
|
||||||
|
observedElementsRef.current.tableViewport &&
|
||||||
|
observer?.observe(observedElementsRef.current.tableViewport)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame)
|
||||||
|
observer?.disconnect()
|
||||||
|
viewport.removeEventListener("scroll", scheduleSync)
|
||||||
|
clearDragState()
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
clearDragState,
|
||||||
|
resetMetrics,
|
||||||
|
syncCustomVerticalScrollbar,
|
||||||
|
usesCustomVerticalScrollbar,
|
||||||
|
])
|
||||||
|
|
||||||
|
const scrollToThumbOffset = (nextThumbTop: number) => {
|
||||||
|
const viewport = viewportRef.current
|
||||||
|
const { thumbHeight, trackHeight } = metricsRef.current
|
||||||
|
|
||||||
|
if (!viewport) return
|
||||||
|
|
||||||
|
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
|
||||||
|
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
||||||
|
|
||||||
|
if (maxScroll === 0 || maxThumbTop === 0) {
|
||||||
|
viewport.scrollTop = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop
|
||||||
|
viewport.scrollTop = ratio * maxScroll
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleThumbPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
const viewport = viewportRef.current
|
||||||
|
|
||||||
|
if (!viewport) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
|
|
||||||
|
dragRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startScrollTop: viewport.scrollTop,
|
||||||
|
startY: event.clientY,
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.style.userSelect = "none"
|
||||||
|
document.body.style.webkitUserSelect = "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleThumbPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
const viewport = viewportRef.current
|
||||||
|
const dragState = dragRef.current
|
||||||
|
const { thumbHeight, trackHeight } = metricsRef.current
|
||||||
|
|
||||||
|
if (!viewport || !dragState || dragState.pointerId !== event.pointerId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
||||||
|
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
|
||||||
|
|
||||||
|
if (maxThumbTop === 0 || maxScroll === 0) return
|
||||||
|
|
||||||
|
const deltaY = event.clientY - dragState.startY
|
||||||
|
const nextScrollTop =
|
||||||
|
dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll
|
||||||
|
|
||||||
|
viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleThumbPointerUp = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (dragRef.current?.pointerId !== event.pointerId) return
|
||||||
|
clearDragState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
const { thumbHeight } = metricsRef.current
|
||||||
|
|
||||||
|
if (event.target !== event.currentTarget) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect()
|
||||||
|
const offsetY = event.clientY - rect.top - thumbHeight / 2
|
||||||
|
|
||||||
|
scrollToThumbOffset(offsetY)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="data-grid-scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
ref={viewportRef}
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="size-full"
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Content data-slot="scroll-area-content">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Content>
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
|
||||||
|
{showHorizontal && (
|
||||||
|
<ScrollAreaPrimitive.Scrollbar
|
||||||
|
data-slot="data-grid-scrollbar"
|
||||||
|
data-orientation="horizontal"
|
||||||
|
orientation="horizontal"
|
||||||
|
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Thumb
|
||||||
|
data-slot="data-grid-thumb"
|
||||||
|
className="bg-border rounded-full relative flex-1"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.Scrollbar>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showVertical && !usesCustomVerticalScrollbar && (
|
||||||
|
<ScrollAreaPrimitive.Scrollbar
|
||||||
|
data-slot="data-grid-scrollbar"
|
||||||
|
data-orientation="vertical"
|
||||||
|
orientation="vertical"
|
||||||
|
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Thumb
|
||||||
|
data-slot="data-grid-thumb"
|
||||||
|
className="bg-border rounded-full relative flex-1"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.Scrollbar>
|
||||||
|
)}
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
|
||||||
|
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="pointer-events-auto relative h-full w-2 touch-none p-px"
|
||||||
|
onPointerDown={handleTrackPointerDown}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-border absolute end-px w-2",
|
||||||
|
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
|
||||||
|
"rounded-full"
|
||||||
|
)}
|
||||||
|
onLostPointerCapture={clearDragState}
|
||||||
|
onPointerCancel={handleThumbPointerUp}
|
||||||
|
onPointerDown={handleThumbPointerDown}
|
||||||
|
onPointerMove={handleThumbPointerMove}
|
||||||
|
onPointerUp={handleThumbPointerUp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DataGridScrollArea }
|
||||||
|
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
CSSProperties,
|
||||||
|
ReactNode,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||||
|
import {
|
||||||
|
DataGridTableBase,
|
||||||
|
DataGridTableBody,
|
||||||
|
DataGridTableBodyRow,
|
||||||
|
DataGridTableBodyRowCell,
|
||||||
|
DataGridTableBodyRowSkeleton,
|
||||||
|
DataGridTableBodyRowSkeletonCell,
|
||||||
|
DataGridTableEmpty,
|
||||||
|
DataGridTableFoot,
|
||||||
|
DataGridTableHead,
|
||||||
|
DataGridTableHeadRow,
|
||||||
|
DataGridTableHeadRowCell,
|
||||||
|
DataGridTableHeadRowCellResize,
|
||||||
|
DataGridTableRowSpacer,
|
||||||
|
DataGridTableViewport,
|
||||||
|
} from "@/components/reui/data-grid/data-grid-table"
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
|
MouseSensor,
|
||||||
|
TouchSensor,
|
||||||
|
UniqueIdentifier,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
type Modifier,
|
||||||
|
} from "@dnd-kit/core"
|
||||||
|
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable"
|
||||||
|
import { CSS } from "@dnd-kit/utilities"
|
||||||
|
import { Cell, flexRender, HeaderGroup, Row } from "@tanstack/react-table"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { GripHorizontalIcon } from "lucide-react"
|
||||||
|
|
||||||
|
// Context to share sortable listeners from row to handle
|
||||||
|
type SortableContextValue = ReturnType<typeof useSortable>
|
||||||
|
const SortableRowContext = createContext<Pick<
|
||||||
|
SortableContextValue,
|
||||||
|
"attributes" | "listeners"
|
||||||
|
> | null>(null)
|
||||||
|
|
||||||
|
function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
||||||
|
const context = useContext(SortableRowContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
// Fallback if context is not available (shouldn't happen in normal usage)
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(
|
||||||
|
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<GripHorizontalIcon
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(
|
||||||
|
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...context.attributes}
|
||||||
|
{...context.listeners}
|
||||||
|
>
|
||||||
|
<GripHorizontalIcon
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
|
||||||
|
const {
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
setNodeRef,
|
||||||
|
isDragging,
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
} = useSortable({
|
||||||
|
id: row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const style: CSSProperties = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition: transition,
|
||||||
|
opacity: isDragging ? 0.8 : 1,
|
||||||
|
zIndex: isDragging ? 1 : 0,
|
||||||
|
position: "relative",
|
||||||
|
cursor: isDragging ? "grabbing" : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableRowContext.Provider value={{ attributes, listeners }}>
|
||||||
|
<DataGridTableBodyRow
|
||||||
|
row={row}
|
||||||
|
dndRef={setNodeRef}
|
||||||
|
dndStyle={style}
|
||||||
|
key={row.id}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => {
|
||||||
|
return (
|
||||||
|
<DataGridTableBodyRowCell cell={cell} key={colIndex}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</DataGridTableBodyRowCell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DataGridTableBodyRow>
|
||||||
|
</SortableRowContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataGridTableDndRows<TData>({
|
||||||
|
handleDragEnd,
|
||||||
|
dataIds,
|
||||||
|
footerContent,
|
||||||
|
}: {
|
||||||
|
handleDragEnd: (event: DragEndEvent) => void
|
||||||
|
dataIds: UniqueIdentifier[]
|
||||||
|
footerContent?: ReactNode
|
||||||
|
}) {
|
||||||
|
const { table, isLoading, props } = useDataGrid()
|
||||||
|
const pagination = table.getState().pagination
|
||||||
|
const tableContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [isDraggingRow, setIsDraggingRow] = useState(false)
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(MouseSensor, {}),
|
||||||
|
useSensor(TouchSensor, {}),
|
||||||
|
useSensor(KeyboardSensor, {})
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDraggingRow) return
|
||||||
|
|
||||||
|
const { body, documentElement } = document
|
||||||
|
const previousBodyCursor = body.style.cursor
|
||||||
|
const previousDocumentCursor = documentElement.style.cursor
|
||||||
|
|
||||||
|
body.style.cursor = "grabbing"
|
||||||
|
documentElement.style.cursor = "grabbing"
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
body.style.cursor = previousBodyCursor
|
||||||
|
documentElement.style.cursor = previousDocumentCursor
|
||||||
|
}
|
||||||
|
}, [isDraggingRow])
|
||||||
|
|
||||||
|
const modifiers = useMemo(() => {
|
||||||
|
const restrictToTableContainer: Modifier = ({
|
||||||
|
transform,
|
||||||
|
draggingNodeRect,
|
||||||
|
}) => {
|
||||||
|
if (!tableContainerRef.current || !draggingNodeRect) {
|
||||||
|
return transform
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerRect = tableContainerRef.current.getBoundingClientRect()
|
||||||
|
const { x, y } = transform
|
||||||
|
|
||||||
|
const minX = containerRect.left - draggingNodeRect.left
|
||||||
|
const maxX = containerRect.right - draggingNodeRect.right
|
||||||
|
const minY = containerRect.top - draggingNodeRect.top
|
||||||
|
const maxY = containerRect.bottom - draggingNodeRect.bottom
|
||||||
|
|
||||||
|
return {
|
||||||
|
...transform,
|
||||||
|
x: Math.max(minX, Math.min(maxX, x)),
|
||||||
|
y: Math.max(minY, Math.min(maxY, y)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [restrictToVerticalAxis, restrictToTableContainer]
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
id={useId()}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
modifiers={modifiers}
|
||||||
|
onDragCancel={() => setIsDraggingRow(false)}
|
||||||
|
onDragEnd={(event) => {
|
||||||
|
setIsDraggingRow(false)
|
||||||
|
handleDragEnd(event)
|
||||||
|
}}
|
||||||
|
onDragStart={() => setIsDraggingRow(true)}
|
||||||
|
sensors={sensors}
|
||||||
|
>
|
||||||
|
<DataGridTableViewport
|
||||||
|
viewportRef={tableContainerRef}
|
||||||
|
className={
|
||||||
|
isDraggingRow
|
||||||
|
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
||||||
|
: "relative"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DataGridTableBase>
|
||||||
|
<DataGridTableHead>
|
||||||
|
{table
|
||||||
|
.getHeaderGroups()
|
||||||
|
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||||
|
return (
|
||||||
|
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||||
|
{headerGroup.headers.map((header, index) => {
|
||||||
|
const { column } = header
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridTableHeadRowCell header={header} key={index}>
|
||||||
|
{header.isPlaceholder ? null : props.tableLayout
|
||||||
|
?.columnsResizable && column.getCanResize() ? (
|
||||||
|
<div className="truncate">
|
||||||
|
{flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{props.tableLayout?.columnsResizable &&
|
||||||
|
column.getCanResize() && (
|
||||||
|
<DataGridTableHeadRowCellResize header={header} />
|
||||||
|
)}
|
||||||
|
</DataGridTableHeadRowCell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DataGridTableHeadRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DataGridTableHead>
|
||||||
|
|
||||||
|
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||||
|
<DataGridTableRowSpacer />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DataGridTableBody>
|
||||||
|
{props.loadingMode === "skeleton" &&
|
||||||
|
isLoading &&
|
||||||
|
pagination?.pageSize ? (
|
||||||
|
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||||
|
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||||
|
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||||
|
return (
|
||||||
|
<DataGridTableBodyRowSkeletonCell
|
||||||
|
column={column}
|
||||||
|
key={colIndex}
|
||||||
|
>
|
||||||
|
{column.columnDef.meta?.skeleton}
|
||||||
|
</DataGridTableBodyRowSkeletonCell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DataGridTableBodyRowSkeleton>
|
||||||
|
))
|
||||||
|
) : table.getRowModel().rows.length ? (
|
||||||
|
<SortableContext
|
||||||
|
items={dataIds}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{table.getRowModel().rows.map((row: Row<TData>) => {
|
||||||
|
return <DataGridTableDndRow row={row} key={row.id} />
|
||||||
|
})}
|
||||||
|
</SortableContext>
|
||||||
|
) : (
|
||||||
|
<DataGridTableEmpty />
|
||||||
|
)}
|
||||||
|
</DataGridTableBody>
|
||||||
|
|
||||||
|
{footerContent && (
|
||||||
|
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||||
|
)}
|
||||||
|
</DataGridTableBase>
|
||||||
|
</DataGridTableViewport>
|
||||||
|
</DndContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
CSSProperties,
|
||||||
|
Fragment,
|
||||||
|
ReactNode,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||||
|
import {
|
||||||
|
DataGridTableBase,
|
||||||
|
DataGridTableBody,
|
||||||
|
DataGridTableBodyRow,
|
||||||
|
DataGridTableBodyRowCell,
|
||||||
|
DataGridTableBodyRowExpandded,
|
||||||
|
DataGridTableBodyRowSkeleton,
|
||||||
|
DataGridTableBodyRowSkeletonCell,
|
||||||
|
DataGridTableEmpty,
|
||||||
|
DataGridTableFoot,
|
||||||
|
DataGridTableHead,
|
||||||
|
DataGridTableHeadRow,
|
||||||
|
DataGridTableHeadRowCell,
|
||||||
|
DataGridTableHeadRowCellResize,
|
||||||
|
DataGridTableRowSpacer,
|
||||||
|
DataGridTableViewport,
|
||||||
|
} from "@/components/reui/data-grid/data-grid-table"
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
|
Modifier,
|
||||||
|
MouseSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from "@dnd-kit/core"
|
||||||
|
import {
|
||||||
|
horizontalListSortingStrategy,
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
} from "@dnd-kit/sortable"
|
||||||
|
import { CSS } from "@dnd-kit/utilities"
|
||||||
|
import {
|
||||||
|
Cell,
|
||||||
|
flexRender,
|
||||||
|
Header,
|
||||||
|
HeaderGroup,
|
||||||
|
Row,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { GripVerticalIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function DataGridTableDndHeader<TData>({
|
||||||
|
header,
|
||||||
|
}: {
|
||||||
|
header: Header<TData, unknown>
|
||||||
|
}) {
|
||||||
|
const { props } = useDataGrid()
|
||||||
|
const { column } = header
|
||||||
|
|
||||||
|
// Check if column ordering is enabled for this column
|
||||||
|
const canOrder =
|
||||||
|
(column.columnDef as { enableColumnOrdering?: boolean })
|
||||||
|
.enableColumnOrdering !== false
|
||||||
|
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
isDragging,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
} = useSortable({
|
||||||
|
id: header.column.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const style: CSSProperties = {
|
||||||
|
opacity: isDragging ? 0.8 : 1,
|
||||||
|
position: "relative",
|
||||||
|
transform: CSS.Translate.toString(transform),
|
||||||
|
transition,
|
||||||
|
cursor: isDragging ? "grabbing" : undefined,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
width: props.tableLayout?.columnsResizable
|
||||||
|
? `calc(var(--header-${header.id}-size) * 1px)`
|
||||||
|
: header.column.getSize(),
|
||||||
|
zIndex: isDragging ? 1 : 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridTableHeadRowCell
|
||||||
|
header={header}
|
||||||
|
dndStyle={style}
|
||||||
|
dndRef={setNodeRef}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-start gap-0.5">
|
||||||
|
{canOrder && (
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
aria-label="Drag to reorder"
|
||||||
|
>
|
||||||
|
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<span className="grow truncate">
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</span>
|
||||||
|
{props.tableLayout?.columnsResizable && column.getCanResize() && (
|
||||||
|
<DataGridTableHeadRowCellResize header={header} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DataGridTableHeadRowCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
|
||||||
|
const { props } = useDataGrid()
|
||||||
|
const { isDragging, setNodeRef, transform, transition } = useSortable({
|
||||||
|
id: cell.column.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const style: CSSProperties = {
|
||||||
|
opacity: isDragging ? 0.8 : 1,
|
||||||
|
position: "relative",
|
||||||
|
transform: CSS.Translate.toString(transform),
|
||||||
|
transition,
|
||||||
|
cursor: isDragging ? "grabbing" : undefined,
|
||||||
|
width: props.tableLayout?.columnsResizable
|
||||||
|
? `calc(var(--col-${cell.column.id}-size) * 1px)`
|
||||||
|
: cell.column.getSize(),
|
||||||
|
zIndex: isDragging ? 1 : 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</DataGridTableBodyRowCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataGridTableDnd<TData>({
|
||||||
|
handleDragEnd,
|
||||||
|
footerContent,
|
||||||
|
}: {
|
||||||
|
handleDragEnd: (event: DragEndEvent) => void
|
||||||
|
footerContent?: ReactNode
|
||||||
|
}) {
|
||||||
|
const { table, isLoading, props } = useDataGrid()
|
||||||
|
const pagination = table.getState().pagination
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(MouseSensor, {}),
|
||||||
|
useSensor(TouchSensor, {}),
|
||||||
|
useSensor(KeyboardSensor, {})
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDraggingColumn) return
|
||||||
|
|
||||||
|
const { body, documentElement } = document
|
||||||
|
const previousBodyCursor = body.style.cursor
|
||||||
|
const previousDocumentCursor = documentElement.style.cursor
|
||||||
|
|
||||||
|
body.style.cursor = "grabbing"
|
||||||
|
documentElement.style.cursor = "grabbing"
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
body.style.cursor = previousBodyCursor
|
||||||
|
documentElement.style.cursor = previousDocumentCursor
|
||||||
|
}
|
||||||
|
}, [isDraggingColumn])
|
||||||
|
|
||||||
|
// Custom modifier to restrict dragging within table bounds with edge offset
|
||||||
|
const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => {
|
||||||
|
if (!draggingNodeRect || !containerRef.current) {
|
||||||
|
return { ...transform, y: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerRect = containerRef.current.getBoundingClientRect()
|
||||||
|
const edgeOffset = 0
|
||||||
|
|
||||||
|
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
|
||||||
|
const maxX =
|
||||||
|
containerRect.right -
|
||||||
|
draggingNodeRect.left -
|
||||||
|
draggingNodeRect.width +
|
||||||
|
edgeOffset
|
||||||
|
|
||||||
|
return {
|
||||||
|
...transform,
|
||||||
|
x: Math.min(Math.max(transform.x, minX), maxX),
|
||||||
|
y: 0, // Lock vertical movement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
id={useId()}
|
||||||
|
modifiers={[restrictToTableBounds]}
|
||||||
|
onDragCancel={() => setIsDraggingColumn(false)}
|
||||||
|
onDragEnd={(event) => {
|
||||||
|
setIsDraggingColumn(false)
|
||||||
|
handleDragEnd(event)
|
||||||
|
}}
|
||||||
|
onDragStart={() => setIsDraggingColumn(true)}
|
||||||
|
sensors={sensors}
|
||||||
|
>
|
||||||
|
<DataGridTableViewport
|
||||||
|
viewportRef={containerRef}
|
||||||
|
className={
|
||||||
|
isDraggingColumn
|
||||||
|
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
||||||
|
: "relative"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DataGridTableBase>
|
||||||
|
<DataGridTableHead>
|
||||||
|
{table
|
||||||
|
.getHeaderGroups()
|
||||||
|
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||||
|
return (
|
||||||
|
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||||
|
<SortableContext
|
||||||
|
items={table.getState().columnOrder}
|
||||||
|
strategy={horizontalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<DataGridTableDndHeader
|
||||||
|
header={header}
|
||||||
|
key={header.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DataGridTableHeadRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DataGridTableHead>
|
||||||
|
|
||||||
|
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||||
|
<DataGridTableRowSpacer />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DataGridTableBody>
|
||||||
|
{props.loadingMode === "skeleton" &&
|
||||||
|
isLoading &&
|
||||||
|
pagination?.pageSize ? (
|
||||||
|
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||||
|
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||||
|
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||||
|
return (
|
||||||
|
<DataGridTableBodyRowSkeletonCell
|
||||||
|
column={column}
|
||||||
|
key={colIndex}
|
||||||
|
>
|
||||||
|
{column.columnDef.meta?.skeleton}
|
||||||
|
</DataGridTableBodyRowSkeletonCell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DataGridTableBodyRowSkeleton>
|
||||||
|
))
|
||||||
|
) : table.getRowModel().rows.length ? (
|
||||||
|
table.getRowModel().rows.map((row: Row<TData>) => {
|
||||||
|
return (
|
||||||
|
<Fragment key={row.id}>
|
||||||
|
<DataGridTableBodyRow row={row}>
|
||||||
|
{row
|
||||||
|
.getVisibleCells()
|
||||||
|
.map((cell: Cell<TData, unknown>) => {
|
||||||
|
return (
|
||||||
|
<SortableContext
|
||||||
|
key={cell.id}
|
||||||
|
items={table.getState().columnOrder}
|
||||||
|
strategy={horizontalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<DataGridTableDndCell cell={cell} />
|
||||||
|
</SortableContext>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DataGridTableBodyRow>
|
||||||
|
{row.getIsExpanded() && (
|
||||||
|
<DataGridTableBodyRowExpandded row={row} />
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<DataGridTableEmpty />
|
||||||
|
)}
|
||||||
|
</DataGridTableBody>
|
||||||
|
|
||||||
|
{footerContent && (
|
||||||
|
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||||
|
)}
|
||||||
|
</DataGridTableBase>
|
||||||
|
</DataGridTableViewport>
|
||||||
|
</DndContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DataGridTableDnd }
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
memo,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||||
|
import {
|
||||||
|
DataGridTableBase,
|
||||||
|
DataGridTableBody,
|
||||||
|
DataGridTableEmpty,
|
||||||
|
DataGridTableFoot,
|
||||||
|
DataGridTableHead,
|
||||||
|
DataGridTableHeadRow,
|
||||||
|
DataGridTableHeadRowCell,
|
||||||
|
DataGridTableHeadRowCellResize,
|
||||||
|
DataGridTableRenderedRow,
|
||||||
|
DataGridTableRowSpacer,
|
||||||
|
DataGridTableViewport,
|
||||||
|
getDataGridTableRowSections,
|
||||||
|
} from "@/components/reui/data-grid/data-grid-table"
|
||||||
|
import { flexRender, HeaderGroup, Row, Table } from "@tanstack/react-table"
|
||||||
|
import {
|
||||||
|
useVirtualizer,
|
||||||
|
VirtualItem,
|
||||||
|
Virtualizer,
|
||||||
|
VirtualizerOptions,
|
||||||
|
} from "@tanstack/react-virtual"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
|
||||||
|
type DataGridTableVirtualScrollElements = {
|
||||||
|
containerElement: HTMLDivElement | null
|
||||||
|
scrollElement: HTMLElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataGridTableVirtualizerInstance = Virtualizer<
|
||||||
|
HTMLElement,
|
||||||
|
HTMLTableRowElement
|
||||||
|
>
|
||||||
|
|
||||||
|
type DataGridTableVirtualizerOptions<TData> = Omit<
|
||||||
|
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
|
||||||
|
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
|
||||||
|
> & {
|
||||||
|
estimateSize?: (index: number, row: Row<TData>) => number
|
||||||
|
getItemKey?: (index: number, row: Row<TData>) => string | number
|
||||||
|
getScrollElement?: (
|
||||||
|
elements: DataGridTableVirtualScrollElements
|
||||||
|
) => HTMLElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataGridTableVirtualProps<TData> {
|
||||||
|
height?: number | string
|
||||||
|
estimateSize?: number
|
||||||
|
overscan?: number
|
||||||
|
footerContent?: ReactNode
|
||||||
|
renderHeader?: boolean
|
||||||
|
onFetchMore?: () => void
|
||||||
|
isFetchingMore?: boolean
|
||||||
|
hasMore?: boolean
|
||||||
|
fetchMoreOffset?: number
|
||||||
|
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VirtualBodyProps<TData> {
|
||||||
|
table: Table<TData>
|
||||||
|
columnCount: number
|
||||||
|
topRows: Row<TData>[]
|
||||||
|
centerRows: Row<TData>[]
|
||||||
|
bottomRows: Row<TData>[]
|
||||||
|
virtualItems: VirtualItem[]
|
||||||
|
totalSize: number
|
||||||
|
isVirtualizationEnabled: boolean
|
||||||
|
isInfiniteMode: boolean
|
||||||
|
isFetchingMore: boolean
|
||||||
|
hasMore?: boolean
|
||||||
|
loadingMoreMessage: ReactNode
|
||||||
|
allRowsLoadedMessage: ReactNode
|
||||||
|
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataGridTableVirtualSpacer({
|
||||||
|
columnCount,
|
||||||
|
height,
|
||||||
|
}: {
|
||||||
|
columnCount: number
|
||||||
|
height: number
|
||||||
|
}) {
|
||||||
|
if (height <= 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr aria-hidden="true">
|
||||||
|
<td colSpan={columnCount} style={{ height, padding: 0 }} />
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataGridTableVirtualStatusRow({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
columnCount,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
columnCount: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={columnCount}
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground py-4 text-center text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataGridTableVirtualBody<TData>({
|
||||||
|
table,
|
||||||
|
columnCount,
|
||||||
|
topRows,
|
||||||
|
centerRows,
|
||||||
|
bottomRows,
|
||||||
|
virtualItems,
|
||||||
|
totalSize,
|
||||||
|
isVirtualizationEnabled,
|
||||||
|
isInfiniteMode,
|
||||||
|
isFetchingMore,
|
||||||
|
hasMore,
|
||||||
|
loadingMoreMessage,
|
||||||
|
allRowsLoadedMessage,
|
||||||
|
measureRowRef,
|
||||||
|
}: VirtualBodyProps<TData>) {
|
||||||
|
const totalRows = topRows.length + centerRows.length + bottomRows.length
|
||||||
|
|
||||||
|
if (!totalRows) return <DataGridTableEmpty />
|
||||||
|
|
||||||
|
const hasCenterRows = centerRows.length > 0
|
||||||
|
const showFetchingRow = isInfiniteMode && isFetchingMore
|
||||||
|
const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0
|
||||||
|
const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow
|
||||||
|
const leadingSpacerHeight =
|
||||||
|
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
||||||
|
? (virtualItems[0]?.start ?? 0)
|
||||||
|
: 0
|
||||||
|
const trailingSpacerHeight =
|
||||||
|
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
||||||
|
? Math.max(
|
||||||
|
0,
|
||||||
|
totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)
|
||||||
|
)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const renderedRows: ReactNode[] = []
|
||||||
|
|
||||||
|
topRows.forEach((row, index) => {
|
||||||
|
renderedRows.push(
|
||||||
|
<DataGridTableRenderedRow
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
pinnedBoundary={
|
||||||
|
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isVirtualizationEnabled) {
|
||||||
|
if (leadingSpacerHeight > 0) {
|
||||||
|
renderedRows.push(
|
||||||
|
<DataGridTableVirtualSpacer
|
||||||
|
key="virtual-spacer-start"
|
||||||
|
columnCount={columnCount}
|
||||||
|
height={leadingSpacerHeight}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
virtualItems.forEach((virtualRow) => {
|
||||||
|
const row = centerRows[virtualRow.index]
|
||||||
|
|
||||||
|
if (!row) return
|
||||||
|
|
||||||
|
renderedRows.push(
|
||||||
|
<DataGridTableRenderedRow
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
rowRef={measureRowRef}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (trailingSpacerHeight > 0) {
|
||||||
|
renderedRows.push(
|
||||||
|
<DataGridTableVirtualSpacer
|
||||||
|
key="virtual-spacer-end"
|
||||||
|
columnCount={columnCount}
|
||||||
|
height={trailingSpacerHeight}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
centerRows.forEach((row) => {
|
||||||
|
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showFetchingRow) {
|
||||||
|
renderedRows.push(
|
||||||
|
<DataGridTableVirtualStatusRow
|
||||||
|
key="virtual-status-loading"
|
||||||
|
columnCount={columnCount}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Spinner className="size-4 opacity-60" />
|
||||||
|
{loadingMoreMessage}
|
||||||
|
</div>
|
||||||
|
</DataGridTableVirtualStatusRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showCompleteRow) {
|
||||||
|
renderedRows.push(
|
||||||
|
<DataGridTableVirtualStatusRow
|
||||||
|
key="virtual-status-complete"
|
||||||
|
columnCount={columnCount}
|
||||||
|
className="py-3 text-xs"
|
||||||
|
>
|
||||||
|
{allRowsLoadedMessage}
|
||||||
|
</DataGridTableVirtualStatusRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
bottomRows.forEach((row, index) => {
|
||||||
|
renderedRows.push(
|
||||||
|
<DataGridTableRenderedRow
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
pinnedBoundary={
|
||||||
|
index === 0 && (topRows.length > 0 || hasMiddleSection)
|
||||||
|
? "bottom"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return <>{renderedRows}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memoized virtual body: skip re-renders during active column resize.
|
||||||
|
* Column widths update via CSS variables on the <table> element,
|
||||||
|
* so the browser handles width changes without React re-renders.
|
||||||
|
*/
|
||||||
|
const MemoizedVirtualBody = memo(
|
||||||
|
DataGridTableVirtualBody,
|
||||||
|
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
|
||||||
|
) as typeof DataGridTableVirtualBody
|
||||||
|
|
||||||
|
function DataGridTableVirtual<TData>({
|
||||||
|
height,
|
||||||
|
estimateSize = 48,
|
||||||
|
overscan = 10,
|
||||||
|
footerContent,
|
||||||
|
renderHeader = true,
|
||||||
|
onFetchMore,
|
||||||
|
isFetchingMore = false,
|
||||||
|
hasMore,
|
||||||
|
fetchMoreOffset = 0,
|
||||||
|
virtualizerOptions,
|
||||||
|
}: DataGridTableVirtualProps<TData>) {
|
||||||
|
const { table, props } = useDataGrid()
|
||||||
|
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
||||||
|
table,
|
||||||
|
props.tableLayout?.rowsPinnable
|
||||||
|
)
|
||||||
|
const columnCount =
|
||||||
|
table.getVisibleFlatColumns().length +
|
||||||
|
(props.tableLayout?.columnsResizable ? 1 : 0)
|
||||||
|
const isInfiniteMode = typeof onFetchMore === "function"
|
||||||
|
const [viewportElements, setViewportElements] =
|
||||||
|
useState<DataGridTableVirtualScrollElements>({
|
||||||
|
containerElement: null,
|
||||||
|
scrollElement: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
estimateSize: customEstimateSize,
|
||||||
|
getItemKey: customGetItemKey,
|
||||||
|
getScrollElement: customGetScrollElement,
|
||||||
|
measureElement: customMeasureElement,
|
||||||
|
overscan: customOverscan,
|
||||||
|
...virtualizerOptionsRest
|
||||||
|
} = virtualizerOptions ?? {}
|
||||||
|
|
||||||
|
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
||||||
|
const loadingMoreMessage =
|
||||||
|
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
||||||
|
const allRowsLoadedMessage =
|
||||||
|
props.allRowsLoadedMessage || "All records loaded"
|
||||||
|
|
||||||
|
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
||||||
|
setViewportElements({
|
||||||
|
containerElement: node,
|
||||||
|
scrollElement:
|
||||||
|
(node?.closest(
|
||||||
|
'[data-slot="scroll-area-viewport"]'
|
||||||
|
) as HTMLElement | null) ?? node,
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const usesExternalScrollArea =
|
||||||
|
viewportElements.scrollElement !== null &&
|
||||||
|
viewportElements.scrollElement !== viewportElements.containerElement
|
||||||
|
|
||||||
|
const resolveScrollElement = useCallback(() => {
|
||||||
|
if (customGetScrollElement) {
|
||||||
|
return customGetScrollElement(viewportElements)
|
||||||
|
}
|
||||||
|
|
||||||
|
return viewportElements.scrollElement
|
||||||
|
}, [customGetScrollElement, viewportElements])
|
||||||
|
|
||||||
|
const resolveItemKey = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const row = centerRows[index]
|
||||||
|
|
||||||
|
if (!row) return index
|
||||||
|
|
||||||
|
return customGetItemKey?.(index, row) ?? row.id ?? index
|
||||||
|
},
|
||||||
|
[centerRows, customGetItemKey]
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolveEstimateSize = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const row = centerRows[index]
|
||||||
|
|
||||||
|
return row
|
||||||
|
? (customEstimateSize?.(index, row) ?? estimateSize)
|
||||||
|
: estimateSize
|
||||||
|
},
|
||||||
|
[centerRows, customEstimateSize, estimateSize]
|
||||||
|
)
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: centerRows.length,
|
||||||
|
getScrollElement: resolveScrollElement,
|
||||||
|
getItemKey: resolveItemKey,
|
||||||
|
estimateSize: resolveEstimateSize,
|
||||||
|
overscan: customOverscan ?? overscan,
|
||||||
|
measureElement: customMeasureElement,
|
||||||
|
...virtualizerOptionsRest,
|
||||||
|
}) as DataGridTableVirtualizerInstance
|
||||||
|
|
||||||
|
const virtualItems = isVirtualizationEnabled
|
||||||
|
? virtualizer.getVirtualItems()
|
||||||
|
: []
|
||||||
|
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
|
||||||
|
const measureRowRef =
|
||||||
|
isVirtualizationEnabled && customMeasureElement
|
||||||
|
? virtualizer.measureElement
|
||||||
|
: undefined
|
||||||
|
const resolvedFetchMoreOffset = useMemo(
|
||||||
|
() => Math.max(0, fetchMoreOffset),
|
||||||
|
[fetchMoreOffset]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!isVirtualizationEnabled ||
|
||||||
|
!isInfiniteMode ||
|
||||||
|
hasMore === false ||
|
||||||
|
isFetchingMore
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastItem = virtualItems[virtualItems.length - 1]
|
||||||
|
if (!lastItem) return
|
||||||
|
|
||||||
|
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
|
||||||
|
onFetchMore?.()
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
centerRows.length,
|
||||||
|
hasMore,
|
||||||
|
isFetchingMore,
|
||||||
|
isInfiniteMode,
|
||||||
|
isVirtualizationEnabled,
|
||||||
|
onFetchMore,
|
||||||
|
resolvedFetchMoreOffset,
|
||||||
|
virtualItems,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridTableViewport
|
||||||
|
viewportRef={handleViewportRef}
|
||||||
|
className={!usesExternalScrollArea ? "block" : undefined}
|
||||||
|
style={
|
||||||
|
usesExternalScrollArea
|
||||||
|
? undefined
|
||||||
|
: { height, overflow: "auto", position: "relative" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DataGridTableBase>
|
||||||
|
{renderHeader && (
|
||||||
|
<DataGridTableHead>
|
||||||
|
{table
|
||||||
|
.getHeaderGroups()
|
||||||
|
.map((headerGroup: HeaderGroup<TData>, index) => (
|
||||||
|
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||||
|
{headerGroup.headers.map((header, hIndex) => {
|
||||||
|
const { column } = header
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridTableHeadRowCell header={header} key={hIndex}>
|
||||||
|
{header.isPlaceholder ? null : props.tableLayout
|
||||||
|
?.columnsResizable && column.getCanResize() ? (
|
||||||
|
<div className="truncate">
|
||||||
|
{flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{props.tableLayout?.columnsResizable &&
|
||||||
|
column.getCanResize() && (
|
||||||
|
<DataGridTableHeadRowCellResize header={header} />
|
||||||
|
)}
|
||||||
|
</DataGridTableHeadRowCell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DataGridTableHeadRow>
|
||||||
|
))}
|
||||||
|
</DataGridTableHead>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderHeader &&
|
||||||
|
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||||
|
<DataGridTableRowSpacer />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DataGridTableBody>
|
||||||
|
<MemoizedVirtualBody
|
||||||
|
table={table}
|
||||||
|
columnCount={columnCount}
|
||||||
|
topRows={topRows}
|
||||||
|
centerRows={centerRows}
|
||||||
|
bottomRows={bottomRows}
|
||||||
|
virtualItems={virtualItems}
|
||||||
|
totalSize={totalSize}
|
||||||
|
isVirtualizationEnabled={isVirtualizationEnabled}
|
||||||
|
isInfiniteMode={isInfiniteMode}
|
||||||
|
isFetchingMore={isFetchingMore}
|
||||||
|
hasMore={hasMore}
|
||||||
|
loadingMoreMessage={loadingMoreMessage}
|
||||||
|
allRowsLoadedMessage={allRowsLoadedMessage}
|
||||||
|
measureRowRef={measureRowRef}
|
||||||
|
/>
|
||||||
|
</DataGridTableBody>
|
||||||
|
|
||||||
|
{footerContent && (
|
||||||
|
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||||
|
)}
|
||||||
|
</DataGridTableBase>
|
||||||
|
</DataGridTableViewport>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DataGridTableVirtual }
|
||||||
|
export type {
|
||||||
|
DataGridTableVirtualProps,
|
||||||
|
DataGridTableVirtualScrollElements,
|
||||||
|
DataGridTableVirtualizerOptions,
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,270 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, ReactNode, useContext, useMemo } from "react"
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
ColumnFiltersState,
|
||||||
|
RowData,
|
||||||
|
SortingState,
|
||||||
|
Table,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
declare module "@tanstack/react-table" {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
interface ColumnMeta<TData extends RowData, TValue> {
|
||||||
|
headerTitle?: string
|
||||||
|
headerClassName?: string
|
||||||
|
cellClassName?: string
|
||||||
|
skeleton?: ReactNode
|
||||||
|
expandedContent?: (row: TData) => ReactNode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */
|
||||||
|
export function getColumnHeaderLabel<TData, TValue>(
|
||||||
|
column: Column<TData, TValue>
|
||||||
|
): string {
|
||||||
|
const meta = column.columnDef.meta as { headerTitle?: string } | undefined
|
||||||
|
if (typeof meta?.headerTitle === "string") return meta.headerTitle
|
||||||
|
const defHeader = column.columnDef.header
|
||||||
|
if (typeof defHeader === "string") return defHeader
|
||||||
|
return String(column.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DataGridApiFetchParams = {
|
||||||
|
pageIndex: number
|
||||||
|
pageSize: number
|
||||||
|
sorting?: SortingState
|
||||||
|
filters?: ColumnFiltersState
|
||||||
|
searchQuery?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DataGridApiResponse<T> = {
|
||||||
|
data: T[]
|
||||||
|
empty: boolean
|
||||||
|
pagination: {
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataGridContextProps<TData extends object> {
|
||||||
|
props: DataGridProps<TData>
|
||||||
|
table: Table<TData>
|
||||||
|
recordCount: number
|
||||||
|
isLoading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DataGridRequestParams = {
|
||||||
|
pageIndex: number
|
||||||
|
pageSize: number
|
||||||
|
sorting?: SortingState
|
||||||
|
columnFilters?: ColumnFiltersState
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataGridProps<TData extends object> {
|
||||||
|
className?: string
|
||||||
|
table?: Table<TData>
|
||||||
|
recordCount: number
|
||||||
|
children?: ReactNode
|
||||||
|
onRowClick?: (row: TData) => void
|
||||||
|
isLoading?: boolean
|
||||||
|
loadingMode?: "skeleton" | "spinner"
|
||||||
|
loadingMessage?: ReactNode | string
|
||||||
|
fetchingMoreMessage?: ReactNode | string
|
||||||
|
allRowsLoadedMessage?: ReactNode | string
|
||||||
|
emptyMessage?: ReactNode | string
|
||||||
|
tableLayout?: {
|
||||||
|
dense?: boolean
|
||||||
|
cellBorder?: boolean
|
||||||
|
rowBorder?: boolean
|
||||||
|
rowRounded?: boolean
|
||||||
|
stripped?: boolean
|
||||||
|
headerBackground?: boolean
|
||||||
|
headerBorder?: boolean
|
||||||
|
headerSticky?: boolean
|
||||||
|
width?: "auto" | "fixed"
|
||||||
|
columnsVisibility?: boolean
|
||||||
|
columnsResizable?: boolean
|
||||||
|
columnsResizeMode?: "onChange" | "onEnd"
|
||||||
|
columnsPinnable?: boolean
|
||||||
|
columnsMovable?: boolean
|
||||||
|
columnsDraggable?: boolean
|
||||||
|
rowsDraggable?: boolean
|
||||||
|
rowsPinnable?: boolean
|
||||||
|
}
|
||||||
|
tableClassNames?: {
|
||||||
|
base?: string
|
||||||
|
header?: string
|
||||||
|
headerRow?: string
|
||||||
|
headerSticky?: string
|
||||||
|
body?: string
|
||||||
|
bodyRow?: string
|
||||||
|
footer?: string
|
||||||
|
edgeCell?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DataGridContext = createContext<
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
DataGridContextProps<any> | undefined
|
||||||
|
>(undefined)
|
||||||
|
|
||||||
|
function useDataGrid() {
|
||||||
|
const context = useContext(DataGridContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useDataGrid must be used within a DataGridProvider")
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataGridProvider<TData extends object>({
|
||||||
|
children,
|
||||||
|
table,
|
||||||
|
...props
|
||||||
|
}: DataGridProps<TData> & { table: Table<TData> }) {
|
||||||
|
const tableState = table.getState()
|
||||||
|
const resolvedColumnsResizeMode =
|
||||||
|
props.tableLayout?.columnsResizeMode ?? "onEnd"
|
||||||
|
|
||||||
|
// Keep resize mode aligned with the DataGrid contract every render so
|
||||||
|
// consumer-level useReactTable options cannot flip it back between drags.
|
||||||
|
if (props.tableLayout?.columnsResizable) {
|
||||||
|
table.options.columnResizeMode = resolvedColumnsResizeMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memoize context value so consumers don't re-render during column resize.
|
||||||
|
// Column sizing state is intentionally excluded from deps -- CSS variables
|
||||||
|
// on the <table> element handle width updates without React re-renders.
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
props,
|
||||||
|
table,
|
||||||
|
recordCount: props.recordCount,
|
||||||
|
isLoading: props.isLoading || false,
|
||||||
|
}),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[
|
||||||
|
table,
|
||||||
|
props.recordCount,
|
||||||
|
props.isLoading,
|
||||||
|
props.loadingMode,
|
||||||
|
props.loadingMessage,
|
||||||
|
props.fetchingMoreMessage,
|
||||||
|
props.allRowsLoadedMessage,
|
||||||
|
props.emptyMessage,
|
||||||
|
props.onRowClick,
|
||||||
|
props.className,
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
JSON.stringify(props.tableLayout),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
JSON.stringify(props.tableClassNames),
|
||||||
|
tableState.sorting,
|
||||||
|
tableState.pagination,
|
||||||
|
tableState.columnFilters,
|
||||||
|
tableState.rowSelection,
|
||||||
|
tableState.expanded,
|
||||||
|
tableState.columnVisibility,
|
||||||
|
tableState.columnOrder,
|
||||||
|
tableState.columnPinning,
|
||||||
|
tableState.globalFilter,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</DataGridContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataGrid<TData extends object>({
|
||||||
|
children,
|
||||||
|
table,
|
||||||
|
...props
|
||||||
|
}: DataGridProps<TData>) {
|
||||||
|
const defaultProps: Partial<DataGridProps<TData>> = {
|
||||||
|
loadingMode: "skeleton",
|
||||||
|
tableLayout: {
|
||||||
|
dense: false,
|
||||||
|
cellBorder: false,
|
||||||
|
rowBorder: true,
|
||||||
|
rowRounded: false,
|
||||||
|
stripped: false,
|
||||||
|
headerSticky: false,
|
||||||
|
headerBackground: true,
|
||||||
|
headerBorder: true,
|
||||||
|
width: "fixed",
|
||||||
|
columnsVisibility: false,
|
||||||
|
columnsResizable: false,
|
||||||
|
columnsResizeMode: "onEnd",
|
||||||
|
columnsPinnable: false,
|
||||||
|
columnsMovable: false,
|
||||||
|
columnsDraggable: false,
|
||||||
|
rowsDraggable: false,
|
||||||
|
rowsPinnable: false,
|
||||||
|
},
|
||||||
|
tableClassNames: {
|
||||||
|
base: "",
|
||||||
|
header: "",
|
||||||
|
headerRow: "",
|
||||||
|
headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs",
|
||||||
|
body: "",
|
||||||
|
bodyRow: "",
|
||||||
|
footer: "",
|
||||||
|
edgeCell: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedProps: DataGridProps<TData> = {
|
||||||
|
...defaultProps,
|
||||||
|
...props,
|
||||||
|
tableLayout: {
|
||||||
|
...defaultProps.tableLayout,
|
||||||
|
...(props.tableLayout || {}),
|
||||||
|
},
|
||||||
|
tableClassNames: {
|
||||||
|
...defaultProps.tableClassNames,
|
||||||
|
...(props.tableClassNames || {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure table is provided
|
||||||
|
if (!table) {
|
||||||
|
throw new Error('DataGrid requires a "table" prop')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridProvider table={table} {...mergedProps}>
|
||||||
|
{children}
|
||||||
|
</DataGridProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataGridContainer({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
border = true,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
border?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="data-grid"
|
||||||
|
className={cn(
|
||||||
|
"w-full overflow-hidden",
|
||||||
|
border &&
|
||||||
|
"border-border rounded-lg border",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export {
|
||||||
|
DataGrid,
|
||||||
|
DataGridContainer,
|
||||||
|
DataGridProvider,
|
||||||
|
useDataGrid,
|
||||||
|
getColumnHeaderLabel,
|
||||||
|
type DataGridProps,
|
||||||
|
type DataGridApiFetchParams,
|
||||||
|
type DataGridApiResponse,
|
||||||
|
} from "./data-grid"
|
||||||
|
export { DataGridColumnFilter } from "./data-grid-column-filter"
|
||||||
|
export { DataGridColumnHeader } from "./data-grid-column-header"
|
||||||
|
export { DataGridColumnVisibility } from "./data-grid-column-visibility"
|
||||||
|
export { DataGridPagination } from "./data-grid-pagination"
|
||||||
|
export { DataGridScrollArea } from "./data-grid-scroll-area"
|
||||||
|
export { DataGridTableDndRowHandle, DataGridTableDndRows } from "./data-grid-table-dnd-rows"
|
||||||
|
export { DataGridTableDnd } from "./data-grid-table-dnd"
|
||||||
|
export { DataGridTableVirtual } from "./data-grid-table-virtual"
|
||||||
|
export {
|
||||||
|
DataGridTable,
|
||||||
|
DataGridTableBase,
|
||||||
|
DataGridTableBody,
|
||||||
|
DataGridTableBodyRow,
|
||||||
|
DataGridTableBodyRowCell,
|
||||||
|
DataGridTableBodyRowExpandded,
|
||||||
|
DataGridTableEmpty,
|
||||||
|
DataGridTableFoot,
|
||||||
|
DataGridTableFootRow,
|
||||||
|
DataGridTableFootRowCell,
|
||||||
|
DataGridTableHead,
|
||||||
|
DataGridTableHeadRow,
|
||||||
|
DataGridTableHeadRowCell,
|
||||||
|
DataGridTableRowSelect,
|
||||||
|
DataGridTableRowSelectAll,
|
||||||
|
getDataGridTableRowSections,
|
||||||
|
} from "./data-grid-table"
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,170 @@
|
|||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS variable architecture for FramePanel theming:
|
||||||
|
*
|
||||||
|
* The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
|
||||||
|
* FramePanel consumes them directly via bg-(--frame-panel-bg) and
|
||||||
|
* border-(--frame-panel-border-color). This means:
|
||||||
|
*
|
||||||
|
* - variant="inverse" overrides those vars on Frame → all panels pick it up
|
||||||
|
* - <FramePanel className="bg-blue-50"> adds a direct utility on the element
|
||||||
|
* which wins over bg-(--frame-panel-bg) by Tailwind source order — no
|
||||||
|
* :not() or !important needed
|
||||||
|
*/
|
||||||
|
const frameVariants = cva(
|
||||||
|
[
|
||||||
|
"relative flex flex-col bg-muted/50 gap-0.75 p-0.75 rounded-(--frame-radius)",
|
||||||
|
"[--frame-radius:var(--radius-xl)]",
|
||||||
|
// Default panel token values — overridden per-variant below
|
||||||
|
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border border-[var(--frame-border-color)] bg-clip-padding",
|
||||||
|
inverse:
|
||||||
|
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
|
||||||
|
ghost: "",
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
xs: "[--frame-panel-p:--spacing(2)] [--frame-panel-header-px:--spacing(2)] [--frame-panel-header-py:--spacing(1)] [--frame-panel-footer-px:--spacing(2)] [--frame-panel-footer-py:--spacing(1)]",
|
||||||
|
sm: "[--frame-panel-p:--spacing(3)] [--frame-panel-header-px:--spacing(3)] [--frame-panel-header-py:--spacing(2)] [--frame-panel-footer-px:--spacing(3)] [--frame-panel-footer-py:--spacing(2)]",
|
||||||
|
default:
|
||||||
|
"[--frame-panel-p:--spacing(4)] [--frame-panel-header-px:--spacing(4)] [--frame-panel-header-py:--spacing(3)] [--frame-panel-footer-px:--spacing(4)] [--frame-panel-footer-py:--spacing(3)]",
|
||||||
|
lg: "[--frame-panel-p:--spacing(5)] [--frame-panel-header-px:--spacing(5)] [--frame-panel-header-py:--spacing(4)] [--frame-panel-footer-px:--spacing(5)] [--frame-panel-footer-py:--spacing(4)]",
|
||||||
|
},
|
||||||
|
stacked: {
|
||||||
|
true: [
|
||||||
|
"gap-0 *:has-[+[data-slot=frame-panel]]:rounded-b-none",
|
||||||
|
"*:has-[+[data-slot=frame-panel]]:before:hidden",
|
||||||
|
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:rounded-t-none",
|
||||||
|
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:border-t-0",
|
||||||
|
// No FrameHeader present: first panel sits flush against the outer frame border
|
||||||
|
"[&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:border-t-0",
|
||||||
|
],
|
||||||
|
false: [
|
||||||
|
"data-[spacing=sm]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-0.5",
|
||||||
|
"data-[spacing=default]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-1",
|
||||||
|
"data-[spacing=lg]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-2",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
dense: {
|
||||||
|
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars
|
||||||
|
true: "p-0 gap-0 border-[var(--frame-border-color)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px",
|
||||||
|
false: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
spacing: "default",
|
||||||
|
stacked: false,
|
||||||
|
dense: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Frame({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
spacing,
|
||||||
|
stacked,
|
||||||
|
dense,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof frameVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
frameVariants({ variant, spacing, stacked, dense }),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="frame"
|
||||||
|
data-spacing={spacing}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FramePanel({
|
||||||
|
className,
|
||||||
|
fit,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { fit?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
|
||||||
|
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
|
||||||
|
// via className overrides these by Tailwind source order — no ! needed.
|
||||||
|
"relative grow overflow-hidden rounded-(--frame-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
|
||||||
|
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-radius)-1px)] before:shadow-black/5",
|
||||||
|
"dark:bg-clip-border dark:before:shadow-white/5",
|
||||||
|
"p-(--frame-panel-p)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="frame-panel"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FrameHeader({ className, ...props }: React.ComponentProps<"header">) {
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col px-(--frame-panel-header-px) py-(--frame-panel-header-py)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="frame-panel-header"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FrameTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("text-sm font-semibold", className)}
|
||||||
|
data-slot="frame-panel-title"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FrameDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
data-slot="frame-panel-description"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FrameFooter({ className, ...props }: React.ComponentProps<"footer">) {
|
||||||
|
return (
|
||||||
|
<footer
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-1 px-(--frame-panel-footer-px) py-(--frame-panel-footer-py)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="frame-panel-footer"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Frame,
|
||||||
|
FramePanel,
|
||||||
|
FrameHeader,
|
||||||
|
FrameTitle,
|
||||||
|
FrameDescription,
|
||||||
|
FrameFooter,
|
||||||
|
frameVariants,
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, ReactNode, useContext, useId } from "react"
|
||||||
|
import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field"
|
||||||
|
import { cva, VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { MinusIcon, PlusIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const NumberFieldContext = createContext<{
|
||||||
|
fieldId: string
|
||||||
|
size: "sm" | "default" | "lg"
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const numberFieldGroupVariants = cva(
|
||||||
|
"relative flex w-full justify-between border border-input data-disabled:pointer-events-none data-disabled:opacity-50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus-within:has-aria-invalid:border-destructive focus-within:has-aria-invalid:ring-destructive/20 dark:focus-within:has-aria-invalid:ring-destructive/40 rounded-lg bg-transparent dark:bg-input/30 transition-colors focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-3",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: "h-7 text-sm",
|
||||||
|
default:
|
||||||
|
"h-8 text-sm",
|
||||||
|
lg: "h-9 text-sm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const numberFieldButtonVariants = cva(
|
||||||
|
"relative flex shrink-0 cursor-pointer items-center justify-center transition-colors pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: "px-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
default:
|
||||||
|
"px-2 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
lg: "px-2.5 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const numberFieldInputVariants = cva(
|
||||||
|
"w-full min-w-0 flex-1 bg-transparent text-center tabular-nums outline-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: "px-2 py-0.5",
|
||||||
|
default:
|
||||||
|
"px-2.5 py-1",
|
||||||
|
lg: "px-2.5 py-1.5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function NumberField({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: NumberFieldPrimitive.Root.Props &
|
||||||
|
VariantProps<typeof numberFieldGroupVariants>) {
|
||||||
|
const generatedId = useId()
|
||||||
|
const fieldId = id ?? generatedId
|
||||||
|
const sizeValue = size ?? "default"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberFieldContext.Provider value={{ fieldId, size: sizeValue }}>
|
||||||
|
<NumberFieldPrimitive.Root
|
||||||
|
className={cn("flex w-full flex-col items-start gap-2", className)}
|
||||||
|
data-size={sizeValue}
|
||||||
|
data-slot="number-field"
|
||||||
|
id={fieldId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</NumberFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberFieldGroup({
|
||||||
|
className,
|
||||||
|
size: sizeProp,
|
||||||
|
...props
|
||||||
|
}: NumberFieldPrimitive.Group.Props &
|
||||||
|
Partial<VariantProps<typeof numberFieldGroupVariants>>) {
|
||||||
|
const context = useContext(NumberFieldContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"NumberFieldGroup must be used within a NumberField component."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const size = sizeProp ?? context.size
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberFieldPrimitive.Group
|
||||||
|
className={cn(numberFieldGroupVariants({ size }), className)}
|
||||||
|
data-slot="number-field-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberFieldDecrement({
|
||||||
|
className,
|
||||||
|
size: sizeProp,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: NumberFieldPrimitive.Decrement.Props &
|
||||||
|
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const context = useContext(NumberFieldContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"NumberFieldDecrement must be used within a NumberField component."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const size = sizeProp ?? context.size
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberFieldPrimitive.Decrement
|
||||||
|
className={cn(
|
||||||
|
numberFieldButtonVariants({ size }),
|
||||||
|
"rounded-s-lg border-e-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="number-field-decrement"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<MinusIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</NumberFieldPrimitive.Decrement>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberFieldIncrement({
|
||||||
|
className,
|
||||||
|
size: sizeProp,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: NumberFieldPrimitive.Increment.Props &
|
||||||
|
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
|
||||||
|
children?: ReactNode
|
||||||
|
}) {
|
||||||
|
const context = useContext(NumberFieldContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"NumberFieldIncrement must be used within a NumberField component."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const size = sizeProp ?? context.size
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberFieldPrimitive.Increment
|
||||||
|
className={cn(
|
||||||
|
numberFieldButtonVariants({ size }),
|
||||||
|
"rounded-e-lg border-s-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="number-field-increment"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<PlusIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</NumberFieldPrimitive.Increment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberFieldInput({
|
||||||
|
className,
|
||||||
|
size: sizeProp,
|
||||||
|
...props
|
||||||
|
}: NumberFieldPrimitive.Input.Props &
|
||||||
|
Partial<VariantProps<typeof numberFieldInputVariants>>) {
|
||||||
|
const context = useContext(NumberFieldContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"NumberFieldInput must be used within a NumberField component."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const size = sizeProp ?? context.size
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberFieldPrimitive.Input
|
||||||
|
className={cn(numberFieldInputVariants({ size }), className)}
|
||||||
|
data-slot="number-field-input"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberFieldScrubArea({
|
||||||
|
className,
|
||||||
|
label,
|
||||||
|
...props
|
||||||
|
}: NumberFieldPrimitive.ScrubArea.Props & {
|
||||||
|
label: string
|
||||||
|
}) {
|
||||||
|
const context = useContext(NumberFieldContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"NumberFieldScrubArea must be used within a NumberField component for accessibility."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberFieldPrimitive.ScrubArea
|
||||||
|
className={cn("flex cursor-ew-resize", className)}
|
||||||
|
data-slot="number-field-scrub-area"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Label className="cursor-ew-resize" htmlFor={context.fieldId}>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
<NumberFieldPrimitive.ScrubAreaCursor className="drop-shadow-[0_1px_1px_#0008] filter">
|
||||||
|
<CursorGrowIcon />
|
||||||
|
</NumberFieldPrimitive.ScrubAreaCursor>
|
||||||
|
</NumberFieldPrimitive.ScrubArea>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CursorGrowIcon(props: React.ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
fill="black"
|
||||||
|
height="14"
|
||||||
|
stroke="white"
|
||||||
|
viewBox="0 0 24 14"
|
||||||
|
width="26"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M19.5 5.5L6.49737 5.51844V2L1 6.9999L6.5 12L6.49737 8.5L19.5 8.5V12L25 6.9999L19.5 2V5.5Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
NumberField,
|
||||||
|
NumberFieldScrubArea,
|
||||||
|
NumberFieldDecrement,
|
||||||
|
NumberFieldIncrement,
|
||||||
|
NumberFieldGroup,
|
||||||
|
NumberFieldInput,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -8,11 +8,11 @@ const buttonVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
outline:
|
outline:
|
||||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
ghost:
|
ghost:
|
||||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
destructive:
|
destructive:
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
} from "@/components/ui/input-group"
|
||||||
|
import { SearchIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
showCloseButton?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
showCloseButton={showCloseButton}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||||
|
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className={cn("py-6 text-center text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("-mx-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||||
|
</CommandPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-header"
|
||||||
|
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMediaVariants = cva(
|
||||||
|
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function EmptyMedia({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-icon"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(emptyMediaVariants({ variant, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-sm font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-content"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Empty,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyTitle,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyMedia,
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
data-slot="field-set"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLegend({
|
||||||
|
className,
|
||||||
|
variant = "legend",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||||
|
return (
|
||||||
|
<legend
|
||||||
|
data-slot="field-legend"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-group"
|
||||||
|
className={cn(
|
||||||
|
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldVariants = cva(
|
||||||
|
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||||
|
horizontal:
|
||||||
|
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
responsive:
|
||||||
|
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="field"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(fieldVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-content"
|
||||||
|
className={cn(
|
||||||
|
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Label>) {
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
||||||
|
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="field-description"
|
||||||
|
className={cn(
|
||||||
|
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||||
|
"last:mt-0 nth-last-2:-mt-1",
|
||||||
|
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-separator"
|
||||||
|
data-content={!!children}
|
||||||
|
className={cn(
|
||||||
|
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Separator className="absolute inset-0 top-1/2" />
|
||||||
|
{children && (
|
||||||
|
<span
|
||||||
|
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||||
|
data-slot="field-separator-content"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
errors,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
errors?: Array<{ message?: string } | undefined>
|
||||||
|
}) {
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (children) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errors?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueErrors = [
|
||||||
|
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (uniqueErrors?.length == 1) {
|
||||||
|
return uniqueErrors[0]?.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||||
|
{uniqueErrors.map(
|
||||||
|
(error, index) =>
|
||||||
|
error?.message && <li key={index}>{error.message}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}, [children, errors])
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-slot="field-error"
|
||||||
|
className={cn("text-sm font-normal text-destructive", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldSet,
|
||||||
|
FieldContent,
|
||||||
|
FieldTitle,
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-group"
|
||||||
|
role="group"
|
||||||
|
className={cn(
|
||||||
|
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupAddonVariants = cva(
|
||||||
|
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
"inline-start":
|
||||||
|
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
|
||||||
|
"inline-end":
|
||||||
|
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
|
||||||
|
"block-start":
|
||||||
|
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
|
||||||
|
"block-end":
|
||||||
|
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: "inline-start",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupAddon({
|
||||||
|
className,
|
||||||
|
align = "inline-start",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="input-group-addon"
|
||||||
|
data-align={align}
|
||||||
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("button")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupButtonVariants = cva(
|
||||||
|
"flex items-center gap-2 text-sm shadow-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||||
|
sm: "",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
|
||||||
|
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "xs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupButton({
|
||||||
|
className,
|
||||||
|
type = "button",
|
||||||
|
variant = "ghost",
|
||||||
|
size = "xs",
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
||||||
|
VariantProps<typeof inputGroupButtonVariants> & {
|
||||||
|
type?: "button" | "submit" | "reset"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type={type}
|
||||||
|
data-size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupTextarea({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupText,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupTextarea,
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd-group"
|
||||||
|
className={cn("inline-flex items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Kbd, KbdGroup }
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ScrollAreaPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Scrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
data-orientation={orientation}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Thumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="relative flex-1 rounded-full bg-border"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.Scrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Value
|
||||||
|
data-slot="select-value"
|
||||||
|
className={cn("flex flex-1 text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Trigger.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon
|
||||||
|
render={
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
alignItemWithTrigger = true,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
SelectPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Positioner
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
alignItemWithTrigger={alignItemWithTrigger}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Popup
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={alignItemWithTrigger}
|
||||||
|
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Popup>
|
||||||
|
</SelectPrimitive.Positioner>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.GroupLabel.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.GroupLabel
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.ItemText>
|
||||||
|
<SelectPrimitive.ItemIndicator
|
||||||
|
render={
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpArrow
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollUpArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownArrow
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollDownArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Loader2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<Loader2Icon data-slot="spinner" role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Spinner }
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -321,7 +321,7 @@ wait_for_health() {
|
|||||||
while (( SECONDS < deadline )); do
|
while (( SECONDS < deadline )); do
|
||||||
if [[ "$health_type" == "http" ]]; then
|
if [[ "$health_type" == "http" ]]; then
|
||||||
local status
|
local status
|
||||||
status="$(curl --silent --output /dev/null --write-out '%{http_code}' --max-time 5 "$health_url" || true)"
|
status="$(curl --silent --location --output /dev/null --write-out '%{http_code}' --max-time 5 "$health_url" || true)"
|
||||||
last_status="$status"
|
last_status="$status"
|
||||||
if [[ "$status" == "$expect_status" ]]; then
|
if [[ "$status" == "$expect_status" ]]; then
|
||||||
log info "target=${target_id} action=health_ok status=${status}"
|
log info "target=${target_id} action=health_ok status=${status}"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"image": "git.shts.su/denozord/mikrotikmanager-frontend:latest",
|
"image": "git.shts.su/denozord/mikrotikmanager-frontend:latest",
|
||||||
"health": {
|
"health": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "http://frontend:3000/",
|
"url": "http://frontend:3000/dashboard",
|
||||||
"expect_status": 200
|
"expect_status": 200
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
|
type DragEvent,
|
||||||
|
type InputHTMLAttributes,
|
||||||
|
} from "react"
|
||||||
|
|
||||||
|
export type FileMetadata = {
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
type: string
|
||||||
|
url: string
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileWithPreview = {
|
||||||
|
file: File | FileMetadata
|
||||||
|
id: string
|
||||||
|
preview?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileUploadOptions = {
|
||||||
|
maxFiles?: number // Only used when multiple is true, defaults to Infinity
|
||||||
|
maxSize?: number // in bytes
|
||||||
|
accept?: string
|
||||||
|
multiple?: boolean // Defaults to false
|
||||||
|
initialFiles?: FileMetadata[]
|
||||||
|
onFilesChange?: (files: FileWithPreview[]) => void // Callback when files change
|
||||||
|
onFilesAdded?: (addedFiles: FileWithPreview[]) => void // Callback when new files are added
|
||||||
|
onError?: (errors: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileUploadState = {
|
||||||
|
files: FileWithPreview[]
|
||||||
|
isDragging: boolean
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileUploadActions = {
|
||||||
|
addFiles: (files: FileList | File[]) => void
|
||||||
|
removeFile: (id: string) => void
|
||||||
|
clearFiles: () => void
|
||||||
|
clearErrors: () => void
|
||||||
|
handleDragEnter: (e: DragEvent<HTMLElement>) => void
|
||||||
|
handleDragLeave: (e: DragEvent<HTMLElement>) => void
|
||||||
|
handleDragOver: (e: DragEvent<HTMLElement>) => void
|
||||||
|
handleDrop: (e: DragEvent<HTMLElement>) => void
|
||||||
|
handleFileChange: (e: ChangeEvent<HTMLInputElement>) => void
|
||||||
|
openFileDialog: () => void
|
||||||
|
getInputProps: (
|
||||||
|
props?: InputHTMLAttributes<HTMLInputElement>
|
||||||
|
) => InputHTMLAttributes<HTMLInputElement> & {
|
||||||
|
ref: React.Ref<HTMLInputElement>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFileUpload = (
|
||||||
|
options: FileUploadOptions = {}
|
||||||
|
): [FileUploadState, FileUploadActions] => {
|
||||||
|
const {
|
||||||
|
maxFiles = Number.POSITIVE_INFINITY,
|
||||||
|
maxSize = Number.POSITIVE_INFINITY,
|
||||||
|
accept = "*",
|
||||||
|
multiple = false,
|
||||||
|
initialFiles = [],
|
||||||
|
onFilesChange,
|
||||||
|
onFilesAdded,
|
||||||
|
onError,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const [state, setState] = useState<FileUploadState>({
|
||||||
|
files: initialFiles.map((file) => ({
|
||||||
|
file,
|
||||||
|
id: file.id,
|
||||||
|
preview: file.url,
|
||||||
|
})),
|
||||||
|
isDragging: false,
|
||||||
|
errors: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const validateFile = useCallback(
|
||||||
|
(file: File | FileMetadata): string | null => {
|
||||||
|
if (file instanceof File) {
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return `File "${file.name}" exceeds the maximum size of ${formatBytes(maxSize)}.`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return `File "${file.name}" exceeds the maximum size of ${formatBytes(maxSize)}.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accept !== "*") {
|
||||||
|
const acceptedTypes = accept.split(",").map((type) => type.trim())
|
||||||
|
const fileType = file instanceof File ? file.type || "" : file.type
|
||||||
|
const fileExtension = `.${file instanceof File ? file.name.split(".").pop() : file.name.split(".").pop()}`
|
||||||
|
|
||||||
|
const isAccepted = acceptedTypes.some((type) => {
|
||||||
|
if (type.startsWith(".")) {
|
||||||
|
return fileExtension.toLowerCase() === type.toLowerCase()
|
||||||
|
}
|
||||||
|
if (type.endsWith("/*")) {
|
||||||
|
const baseType = type.split("/")[0]
|
||||||
|
return fileType.startsWith(`${baseType}/`)
|
||||||
|
}
|
||||||
|
return fileType === type
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isAccepted) {
|
||||||
|
return `File "${file instanceof File ? file.name : file.name}" is not an accepted file type.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
[accept, maxSize]
|
||||||
|
)
|
||||||
|
|
||||||
|
const createPreview = useCallback(
|
||||||
|
(file: File | FileMetadata): string | undefined => {
|
||||||
|
if (file instanceof File) {
|
||||||
|
return URL.createObjectURL(file)
|
||||||
|
}
|
||||||
|
return file.url
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const generateUniqueId = useCallback((file: File | FileMetadata): string => {
|
||||||
|
if (file instanceof File) {
|
||||||
|
return `${file.name}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
||||||
|
}
|
||||||
|
return file.id
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearFiles = useCallback(() => {
|
||||||
|
setState((prev) => {
|
||||||
|
// Clean up object URLs
|
||||||
|
for (const file of prev.files) {
|
||||||
|
if (
|
||||||
|
file.preview &&
|
||||||
|
file.file instanceof File &&
|
||||||
|
file.file.type.startsWith("image/")
|
||||||
|
) {
|
||||||
|
URL.revokeObjectURL(file.preview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const newState = {
|
||||||
|
...prev,
|
||||||
|
files: [],
|
||||||
|
errors: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilesChange?.(newState.files)
|
||||||
|
return newState
|
||||||
|
})
|
||||||
|
}, [onFilesChange])
|
||||||
|
|
||||||
|
const addFiles = useCallback(
|
||||||
|
(newFiles: FileList | File[]) => {
|
||||||
|
if (!newFiles || newFiles.length === 0) return
|
||||||
|
|
||||||
|
const newFilesArray = Array.from(newFiles)
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
// Clear existing errors when new files are uploaded
|
||||||
|
setState((prev) => ({ ...prev, errors: [] }))
|
||||||
|
|
||||||
|
// In single file mode, clear existing files first
|
||||||
|
if (!multiple) {
|
||||||
|
clearFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if adding these files would exceed maxFiles (only in multiple mode)
|
||||||
|
if (
|
||||||
|
multiple &&
|
||||||
|
maxFiles !== Number.POSITIVE_INFINITY &&
|
||||||
|
state.files.length + newFilesArray.length > maxFiles
|
||||||
|
) {
|
||||||
|
errors.push(`You can only upload a maximum of ${maxFiles} files.`)
|
||||||
|
onError?.(errors)
|
||||||
|
setState((prev) => ({ ...prev, errors }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const validFiles: FileWithPreview[] = []
|
||||||
|
|
||||||
|
for (const file of newFilesArray) {
|
||||||
|
// Only check for duplicates if multiple files are allowed
|
||||||
|
if (multiple) {
|
||||||
|
const isDuplicate = state.files.some(
|
||||||
|
(existingFile) =>
|
||||||
|
existingFile.file.name === file.name &&
|
||||||
|
existingFile.file.size === file.size
|
||||||
|
)
|
||||||
|
|
||||||
|
// Skip duplicate files silently
|
||||||
|
if (isDuplicate) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
errors.push(
|
||||||
|
multiple
|
||||||
|
? `Some files exceed the maximum size of ${formatBytes(maxSize)}.`
|
||||||
|
: `File exceeds the maximum size of ${formatBytes(maxSize)}.`
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = validateFile(file)
|
||||||
|
if (error) {
|
||||||
|
errors.push(error)
|
||||||
|
} else {
|
||||||
|
validFiles.push({
|
||||||
|
file,
|
||||||
|
id: generateUniqueId(file),
|
||||||
|
preview: createPreview(file),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update state if we have valid files to add
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
// Call the onFilesAdded callback with the newly added valid files
|
||||||
|
onFilesAdded?.(validFiles)
|
||||||
|
|
||||||
|
setState((prev) => {
|
||||||
|
const newFiles = !multiple
|
||||||
|
? validFiles
|
||||||
|
: [...prev.files, ...validFiles]
|
||||||
|
onFilesChange?.(newFiles)
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
files: newFiles,
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (errors.length > 0) {
|
||||||
|
onError?.(errors)
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
errors,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset input value after handling files
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
state.files,
|
||||||
|
maxFiles,
|
||||||
|
multiple,
|
||||||
|
maxSize,
|
||||||
|
validateFile,
|
||||||
|
createPreview,
|
||||||
|
generateUniqueId,
|
||||||
|
clearFiles,
|
||||||
|
onFilesChange,
|
||||||
|
onFilesAdded,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeFile = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setState((prev) => {
|
||||||
|
const fileToRemove = prev.files.find((file) => file.id === id)
|
||||||
|
if (
|
||||||
|
fileToRemove &&
|
||||||
|
fileToRemove.preview &&
|
||||||
|
fileToRemove.file instanceof File &&
|
||||||
|
fileToRemove.file.type.startsWith("image/")
|
||||||
|
) {
|
||||||
|
URL.revokeObjectURL(fileToRemove.preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFiles = prev.files.filter((file) => file.id !== id)
|
||||||
|
onFilesChange?.(newFiles)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
files: newFiles,
|
||||||
|
errors: [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[onFilesChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearErrors = useCallback(() => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
errors: [],
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragEnter = useCallback((e: DragEvent<HTMLElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setState((prev) => ({ ...prev, isDragging: true }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: DragEvent<HTMLElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
if (e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((prev) => ({ ...prev, isDragging: false }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: DragEvent<HTMLElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: DragEvent<HTMLElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setState((prev) => ({ ...prev, isDragging: false }))
|
||||||
|
|
||||||
|
// Don't process files if the input is disabled
|
||||||
|
if (inputRef.current?.disabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||||
|
// In single file mode, only use the first file
|
||||||
|
if (!multiple) {
|
||||||
|
const file = e.dataTransfer.files[0]
|
||||||
|
addFiles([file])
|
||||||
|
} else {
|
||||||
|
addFiles(e.dataTransfer.files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addFiles, multiple]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
addFiles(e.target.files)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addFiles]
|
||||||
|
)
|
||||||
|
|
||||||
|
const openFileDialog = useCallback(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.click()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const getInputProps = useCallback(
|
||||||
|
(props: InputHTMLAttributes<HTMLInputElement> = {}) => {
|
||||||
|
return {
|
||||||
|
...props,
|
||||||
|
type: "file" as const,
|
||||||
|
onChange: handleFileChange,
|
||||||
|
accept: props.accept || accept,
|
||||||
|
multiple: props.multiple !== undefined ? props.multiple : multiple,
|
||||||
|
ref: inputRef,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[accept, multiple, handleFileChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
addFiles,
|
||||||
|
removeFile,
|
||||||
|
clearFiles,
|
||||||
|
clearErrors,
|
||||||
|
handleDragEnter,
|
||||||
|
handleDragLeave,
|
||||||
|
handleDragOver,
|
||||||
|
handleDrop,
|
||||||
|
handleFileChange,
|
||||||
|
openFileDialog,
|
||||||
|
getInputProps,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to format bytes to human-readable format
|
||||||
|
export const formatBytes = (bytes: number, decimals = 2): string => {
|
||||||
|
if (bytes === 0) return "0 Bytes"
|
||||||
|
|
||||||
|
const k = 1024
|
||||||
|
const dm = decimals < 0 ? 0 : decimals
|
||||||
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
|
||||||
|
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
|
||||||
|
return Number.parseFloat((bytes / k ** i).toFixed(dm)) + sizes[i]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function backendInternalUrl(): string {
|
||||||
|
return (process.env.BACKEND_INTERNAL_URL ?? "http://127.0.0.1:8000").replace(/\/$/, "")
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { BgpSessionRow } from "@/lib/bgp/types"
|
||||||
|
|
||||||
|
export function fmtBgpNum(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`
|
||||||
|
return String(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rscBgpSnippet(s: BgpSessionRow): string {
|
||||||
|
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`
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export type BgpState = "Established" | "Active" | "Idle" | "Connect" | "OpenSent" | "OpenConfirm"
|
||||||
|
export type BgpType = "eBGP" | "iBGP"
|
||||||
|
export type BgpAfi = "IPv4 Unicast" | "IPv6 Unicast" | "VPNv4 Unicast"
|
||||||
|
|
||||||
|
export interface BgpSessionRow {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BGP_AS_NAMES: Record<number, string> = {
|
||||||
|
8359: "МТС / Tele2",
|
||||||
|
13238: "Яндекс",
|
||||||
|
12389: "Ростелеком",
|
||||||
|
24940: "Hetzner",
|
||||||
|
6777: "AMS-IX",
|
||||||
|
1299: "Telia",
|
||||||
|
65001: "iBGP internal",
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Filter } from "@/components/reui/filters"
|
||||||
|
|
||||||
|
type FieldAccessor<T> = Record<string, (row: T) => unknown>
|
||||||
|
|
||||||
|
function matchFilter<T>(row: T, filter: Filter, accessors: FieldAccessor<T>): boolean {
|
||||||
|
const getValue = accessors[filter.field]
|
||||||
|
if (!getValue) return true
|
||||||
|
|
||||||
|
const value = getValue(row)
|
||||||
|
const vals = filter.values
|
||||||
|
const str = (v: unknown) => String(v ?? "").toLowerCase()
|
||||||
|
|
||||||
|
switch (filter.operator) {
|
||||||
|
case "is":
|
||||||
|
return vals.length > 0 && String(value) === String(vals[0])
|
||||||
|
case "isNot":
|
||||||
|
return vals.length === 0 || String(value) !== String(vals[0])
|
||||||
|
case "isAnyOf":
|
||||||
|
return vals.some((v) => String(value) === String(v))
|
||||||
|
case "isNotAnyOf":
|
||||||
|
return !vals.some((v) => String(value) === String(v))
|
||||||
|
case "contains":
|
||||||
|
return vals.some((v) => str(value).includes(str(v)))
|
||||||
|
case "doesNotContain":
|
||||||
|
return !vals.some((v) => str(value).includes(str(v)))
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyReuiFilters<T>(
|
||||||
|
rows: T[],
|
||||||
|
filters: Filter[],
|
||||||
|
accessors: FieldAccessor<T>,
|
||||||
|
): T[] {
|
||||||
|
if (filters.length === 0) return rows
|
||||||
|
return rows.filter((row) =>
|
||||||
|
filters.every((f) => matchFilter(row, f, accessors)),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { FilterFieldConfig } from "@/components/reui/filters"
|
||||||
|
|
||||||
|
export const BGP_FILTER_FIELDS: FilterFieldConfig[] = [
|
||||||
|
{
|
||||||
|
key: "state",
|
||||||
|
label: "Состояние",
|
||||||
|
type: "multiselect",
|
||||||
|
options: [
|
||||||
|
{ value: "Established", label: "Established" },
|
||||||
|
{ value: "Active", label: "Active" },
|
||||||
|
{ value: "Idle", label: "Idle" },
|
||||||
|
{ value: "Connect", label: "Connect" },
|
||||||
|
{ value: "OpenSent", label: "OpenSent" },
|
||||||
|
{ value: "OpenConfirm", label: "OpenConfirm" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "type",
|
||||||
|
label: "Тип",
|
||||||
|
type: "multiselect",
|
||||||
|
options: [
|
||||||
|
{ value: "eBGP", label: "eBGP" },
|
||||||
|
{ value: "iBGP", label: "iBGP" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "afi",
|
||||||
|
label: "AFI",
|
||||||
|
type: "multiselect",
|
||||||
|
options: [
|
||||||
|
{ value: "IPv4 Unicast", label: "IPv4 Unicast" },
|
||||||
|
{ value: "IPv6 Unicast", label: "IPv6 Unicast" },
|
||||||
|
{ value: "VPNv4 Unicast", label: "VPNv4 Unicast" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { FilterFieldConfig } from "@/components/reui/filters"
|
||||||
|
import type { Server, ServerType } from "@/lib/data"
|
||||||
|
|
||||||
|
export const SERVER_FILTER_FIELDS: FilterFieldConfig[] = [
|
||||||
|
{
|
||||||
|
key: "country",
|
||||||
|
label: "Страна",
|
||||||
|
type: "multiselect",
|
||||||
|
searchable: true,
|
||||||
|
options: [
|
||||||
|
{ value: "RU", label: "RU" },
|
||||||
|
{ value: "DE", label: "DE" },
|
||||||
|
{ value: "NL", label: "NL" },
|
||||||
|
{ value: "SG", label: "SG" },
|
||||||
|
{ value: "FI", label: "FI" },
|
||||||
|
{ value: "US", label: "US" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "Статус",
|
||||||
|
type: "multiselect",
|
||||||
|
options: [
|
||||||
|
{ value: "online", label: "Online" },
|
||||||
|
{ value: "degraded", label: "Degraded" },
|
||||||
|
{ value: "offline", label: "Offline" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "type",
|
||||||
|
label: "Тип",
|
||||||
|
type: "multiselect",
|
||||||
|
options: [
|
||||||
|
{ value: "jump-host", label: "JumpHost" },
|
||||||
|
{ value: "exit-node", label: "Exit Node" },
|
||||||
|
{ value: "home-router", label: "Home Router" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const SERVER_FILTER_ACCESSORS = {
|
||||||
|
country: (s: Server) => s.country ?? "",
|
||||||
|
status: (s: Server) => s.status,
|
||||||
|
type: (s: Server) => s.type as ServerType,
|
||||||
|
}
|
||||||
+3
-7
@@ -59,13 +59,9 @@ function normalizeBackendUrl(url: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DataSourceProvider({ children }: { children: React.ReactNode }) {
|
export function DataSourceProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [mode, setModeState] = useState<DataSourceMode>(() =>
|
const [mode, setModeState] = useState<DataSourceMode>(defaultDataSourceMode)
|
||||||
typeof window === "undefined" ? defaultDataSourceMode() : readStoredMode(),
|
const [backendUrl, setBackendUrlState] = useState(LOCAL_DEFAULT_BACKEND_URL)
|
||||||
)
|
const [prefsHydrated, setPrefsHydrated] = useState(false)
|
||||||
const [backendUrl, setBackendUrlState] = useState(() =>
|
|
||||||
typeof window === "undefined" ? LOCAL_DEFAULT_BACKEND_URL : readStoredBackendUrl(),
|
|
||||||
)
|
|
||||||
const [prefsHydrated, setPrefsHydrated] = useState(() => typeof window !== "undefined")
|
|
||||||
const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined)
|
const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined)
|
||||||
const backendUrlLocked = isBackendUrlLocked()
|
const backendUrlLocked = isBackendUrlLocked()
|
||||||
const mockModeAvailable = isMockDataSourceAvailable()
|
const mockModeAvailable = isMockDataSourceAvailable()
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { NextRequest } from "next/server"
|
||||||
|
import { backendInternalUrl } from "@/lib/backend-internal-url"
|
||||||
|
|
||||||
|
type ProxyBackendRequestOptions = {
|
||||||
|
method?: string
|
||||||
|
forwardRequestBody?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function proxyBackendRequest(
|
||||||
|
request: NextRequest,
|
||||||
|
backendPath: string,
|
||||||
|
options: ProxyBackendRequestOptions = {},
|
||||||
|
): Promise<Response> {
|
||||||
|
const method = options.method ?? request.method
|
||||||
|
const headers = new Headers()
|
||||||
|
for (const [key, value] of request.headers.entries()) {
|
||||||
|
const lower = key.toLowerCase()
|
||||||
|
if (lower === "host" || lower === "connection") continue
|
||||||
|
headers.set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchInit: RequestInit & { duplex?: "half" } = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
cache: "no-store",
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwardRequestBody = options.forwardRequestBody ?? !["GET", "HEAD"].includes(method)
|
||||||
|
if (forwardRequestBody && request.body) {
|
||||||
|
fetchInit.body = request.body
|
||||||
|
fetchInit.duplex = "half"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(`${backendInternalUrl()}${backendPath}`, fetchInit)
|
||||||
|
const responseHeaders = new Headers()
|
||||||
|
const contentType = upstream.headers.get("Content-Type")
|
||||||
|
const contentDisposition = upstream.headers.get("Content-Disposition")
|
||||||
|
if (contentType) responseHeaders.set("Content-Type", contentType)
|
||||||
|
if (contentDisposition) responseHeaders.set("Content-Disposition", contentDisposition)
|
||||||
|
|
||||||
|
return new Response(upstream.body, {
|
||||||
|
status: upstream.status,
|
||||||
|
headers: responseHeaders,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Не удалось связаться с бекендом"
|
||||||
|
return Response.json({ error: message }, { status: 502 })
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-7
@@ -1,16 +1,16 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import { backendInternalUrl } from "./lib/backend-internal-url";
|
||||||
const backendInternalUrl = (process.env.BACKEND_INTERNAL_URL ?? "http://127.0.0.1:8000").replace(
|
|
||||||
/\/$/,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
experimental: {
|
||||||
|
proxyClientMaxBodySize: "512mb",
|
||||||
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
|
const backendUrl = backendInternalUrl();
|
||||||
return [
|
return [
|
||||||
{ source: "/health", destination: `${backendInternalUrl}/health` },
|
{ source: "/health", destination: `${backendUrl}/health` },
|
||||||
{ source: "/api/:path*", destination: `${backendInternalUrl}/api/:path*` },
|
{ source: "/api/:path*", destination: `${backendUrl}/api/:path*` },
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+620
-1
@@ -14,9 +14,16 @@
|
|||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.4.1",
|
"@base-ui/react": "^1.4.1",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@mmapp/contracts": "1.0.0",
|
"@mmapp/contracts": "1.0.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.14.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"lucide-react": "^1.11.0",
|
"lucide-react": "^1.11.0",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
@@ -570,6 +577,73 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/modifiers": {
|
||||||
|
"version": "9.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@dotenvx/dotenvx": {
|
"node_modules/@dotenvx/dotenvx": {
|
||||||
"version": "1.63.0",
|
"version": "1.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.63.0.tgz",
|
||||||
@@ -3239,6 +3313,336 @@
|
|||||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-compose-refs": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-dialog": {
|
||||||
|
"version": "1.1.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz",
|
||||||
|
"integrity": "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.4",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.3",
|
||||||
|
"@radix-ui/react-context": "1.1.4",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.13",
|
||||||
|
"@radix-ui/react-focus-guards": "1.1.4",
|
||||||
|
"@radix-ui/react-focus-scope": "1.1.10",
|
||||||
|
"@radix-ui/react-id": "1.1.2",
|
||||||
|
"@radix-ui/react-portal": "1.1.12",
|
||||||
|
"@radix-ui/react-presence": "1.1.6",
|
||||||
|
"@radix-ui/react-primitive": "2.1.6",
|
||||||
|
"@radix-ui/react-slot": "1.3.0",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.3",
|
||||||
|
"aria-hidden": "^1.2.4",
|
||||||
|
"react-remove-scroll": "^2.7.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||||
|
"version": "1.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz",
|
||||||
|
"integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.4",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.3",
|
||||||
|
"@radix-ui/react-primitive": "2.1.6",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.2",
|
||||||
|
"@radix-ui/react-use-escape-keydown": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-focus-guards": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-focus-scope": {
|
||||||
|
"version": "1.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz",
|
||||||
|
"integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.3",
|
||||||
|
"@radix-ui/react-primitive": "2.1.6",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-id": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-portal": {
|
||||||
|
"version": "1.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz",
|
||||||
|
"integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.6",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-presence": {
|
||||||
|
"version": "1.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz",
|
||||||
|
"integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz",
|
||||||
|
"integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-effect-event": "0.0.3",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-effect-event": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-escape-keydown": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@@ -3544,6 +3948,66 @@
|
|||||||
"tailwindcss": "4.2.4"
|
"tailwindcss": "4.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-table": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/table-core": "8.21.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8",
|
||||||
|
"react-dom": ">=16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/react-virtual": {
|
||||||
|
"version": "3.14.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.4.tgz",
|
||||||
|
"integrity": "sha512-dZzAQP2uCDAd+9sAehqmx/DcU+B91Q4Gb0aDSM7t9bJvWDyGF9sapFNW5r1gNLsHs4wTb6ScZENJeYaHxJLiOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/virtual-core": "3.17.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/table-core": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/virtual-core": {
|
||||||
|
"version": "3.17.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.2.tgz",
|
||||||
|
"integrity": "sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@ts-morph/common": {
|
"node_modules/@ts-morph/common": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
|
||||||
@@ -3684,7 +4148,7 @@
|
|||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
@@ -4431,6 +4895,18 @@
|
|||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/aria-hidden": {
|
||||||
|
"version": "1.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||||
|
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/aria-query": {
|
"node_modules/aria-query": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||||
@@ -5152,6 +5628,22 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cmdk": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
|
"@radix-ui/react-id": "^1.1.0",
|
||||||
|
"@radix-ui/react-primitive": "^2.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/code-block-writer": {
|
"node_modules/code-block-writer": {
|
||||||
"version": "13.0.3",
|
"version": "13.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
|
||||||
@@ -5594,6 +6086,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-node-es": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/diff": {
|
"node_modules/diff": {
|
||||||
"version": "8.0.4",
|
"version": "8.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
|
||||||
@@ -7280,6 +7778,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-nonce": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-own-enumerable-keys": {
|
"node_modules/get-own-enumerable-keys": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-own-enumerable-keys/-/get-own-enumerable-keys-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-own-enumerable-keys/-/get-own-enumerable-keys-1.0.0.tgz",
|
||||||
@@ -10308,6 +10815,75 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-remove-scroll": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-remove-scroll-bar": "^2.3.7",
|
||||||
|
"react-style-singleton": "^2.2.3",
|
||||||
|
"tslib": "^2.1.0",
|
||||||
|
"use-callback-ref": "^1.3.3",
|
||||||
|
"use-sidecar": "^1.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-remove-scroll-bar": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-style-singleton": "^2.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-style-singleton": {
|
||||||
|
"version": "2.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||||
|
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"get-nonce": "^1.0.0",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readable-stream": {
|
"node_modules/readable-stream": {
|
||||||
"version": "3.6.2",
|
"version": "3.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
@@ -12603,6 +13179,49 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-callback-ref": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/use-sidecar": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-node-es": "^1.1.0",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/use-sync-external-store": {
|
"node_modules/use-sync-external-store": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
|||||||
+8
-1
@@ -15,10 +15,17 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mmapp/contracts": "1.0.0",
|
|
||||||
"@base-ui/react": "^1.4.1",
|
"@base-ui/react": "^1.4.1",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@mmapp/contracts": "1.0.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.14.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"lucide-react": "^1.11.0",
|
"lucide-react": "^1.11.0",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { ApiClientError } from "@/shared/api/http-client"
|
import { ApiClientError } from "@/shared/api/http-client"
|
||||||
|
import { configuredBackendUrl } from "@/lib/backend-url"
|
||||||
|
|
||||||
|
const MAX_RESTORE_BYTES = 512 * 1024 * 1024
|
||||||
|
|
||||||
function trimBaseUrl(baseUrl: string): string {
|
function trimBaseUrl(baseUrl: string): string {
|
||||||
return baseUrl.replace(/\/$/, "")
|
return baseUrl.replace(/\/$/, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveDatabaseApiUrl(baseUrl: string, path: string): string {
|
||||||
|
if (configuredBackendUrl().kind === "same-origin") {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return `${trimBaseUrl(baseUrl)}${path}`
|
||||||
|
}
|
||||||
|
|
||||||
function parseFilename(contentDisposition: string | null, fallback: string): string {
|
function parseFilename(contentDisposition: string | null, fallback: string): string {
|
||||||
if (!contentDisposition) return fallback
|
if (!contentDisposition) return fallback
|
||||||
const utfMatch = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition)
|
const utfMatch = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition)
|
||||||
@@ -22,7 +32,7 @@ function parseFilename(contentDisposition: string | null, fallback: string): str
|
|||||||
export async function downloadSystemDatabaseBackup(
|
export async function downloadSystemDatabaseBackup(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
): Promise<{ blob: Blob; filename: string }> {
|
): Promise<{ blob: Blob; filename: string }> {
|
||||||
const res = await fetch(`${trimBaseUrl(baseUrl)}/api/system/database/backup`)
|
const res = await fetch(resolveDatabaseApiUrl(baseUrl, "/api/system/database/backup"))
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const payload = await res.json().catch(() => undefined)
|
const payload = await res.json().catch(() => undefined)
|
||||||
const msg =
|
const msg =
|
||||||
@@ -40,11 +50,16 @@ export async function downloadSystemDatabaseBackup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function restoreSystemDatabaseBackup(baseUrl: string, file: File): Promise<void> {
|
export async function restoreSystemDatabaseBackup(baseUrl: string, file: File): Promise<void> {
|
||||||
const body = await file.arrayBuffer()
|
if (file.size > MAX_RESTORE_BYTES) {
|
||||||
const res = await fetch(`${trimBaseUrl(baseUrl)}/api/system/database/restore`, {
|
throw new ApiClientError(
|
||||||
|
`Файл больше ${MAX_RESTORE_BYTES / (1024 * 1024)} МБ — уменьшите бэкап или обратитесь к администратору`,
|
||||||
|
413,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const res = await fetch(resolveDatabaseApiUrl(baseUrl, "/api/system/database/restore"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/octet-stream" },
|
headers: { "Content-Type": "application/octet-stream" },
|
||||||
body,
|
body: file,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const payload = await res.json().catch(() => undefined)
|
const payload = await res.json().catch(() => undefined)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user