fix(ui): убрать неподтверждённые данные и действия GRE-пулов

This commit is contained in:
Denozordec committed 2026-09-30 16:10:21 +07:00
1 parent 337d14abdf
commit fc3d588cec
6 files changed
+55 -139

No files matched your search

+13 -83
View File
@@ -11,7 +11,7 @@ import { GreTunnelsDataGrid } from "@/components/data-grids/gre-tunnels-data-gri
import { GrePoolsDataGrid } from "@/components/data-grids/gre-pools-data-grid"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data"
import type { GrePool, GreTunnel, GreStatus, Server } from "@/lib/data"
import type { GreTunnel, GreStatus, Server } from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import { generateGreRsc } from "@/lib/gre-export"
import { greResponseSchema } from "@/lib/gre-response"
@@ -96,22 +96,6 @@ function mapBackendToServer(s: BackendServer): InventoryServer {
}
}
function derivePoolsFromTunnels(tunnels: GreTunnel[]): GrePool[] {
const ids = [...new Set(tunnels.map((t) => t.poolId).filter(Boolean))]
return ids.map((id) => {
const list = tunnels.filter((t) => t.poolId === id)
const allocated = list.length
return {
id,
name: id === "live" ? "С устройств (опрос)" : id,
cidr: "—",
allocated,
total: Math.max(allocated, 1),
comment: "По poolId из данных GRE",
}
})
}
// ─── default form states ─────────────────────────────────────────────────────
const defaultTunnelForm = {
@@ -123,7 +107,6 @@ const defaultTunnelForm = {
showAdvanced: false,
}
const defaultPoolForm = { name: "", cidr: "", comment: "" }
type TabFilter = "all" | "up" | "ipsec" | "plain"
type PageTab = "tunnels" | "pools"
@@ -180,12 +163,10 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
const [historyError, setHistoryError] = useState<string | null>(null)
const historyEpoch = useRef(0)
const [historyRestoring, setHistoryRestoring] = useState(false)
const [poolOpen, setPoolOpen] = useState(false)
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
const [exportContext, setExportContext] = useState<{ servers: Record<string, Server>; at: string | null; stale: boolean; mock: boolean } | null>(null)
const [tForm, setTForm] = useState(defaultTunnelForm)
const [pForm, setPForm] = useState(defaultPoolForm)
const setT = <K extends keyof typeof defaultTunnelForm>(k: K, v: (typeof defaultTunnelForm)[K]) =>
setTForm((f) => ({ ...f, [k]: v }))
@@ -240,8 +221,8 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
const displayTunnels = isLive ? liveTunnels : mockGreTunnels
const displayServers = isLive ? liveServers : mockServers
const displayPools = useMemo(
() => (isLive ? derivePoolsFromTunnels(displayTunnels) : mockGrePools),
[isLive, displayTunnels],
() => (isLive ? [] : mockGrePools),
[isLive],
)
const greRailItems = useMemo<ServerTileItem[]>(() => (
@@ -670,8 +651,8 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
{
id: "pools",
label: "IP-пулов",
value: incomplete ? "—" : displayPools.length,
hint: incomplete ? "неизвестно" : undefined,
value: isLive ? "—" : displayPools.length,
hint: isLive ? "нет данных API" : "моковые данные",
icon: <DatabaseIcon className="size-4" />,
iconClassName: "text-primary",
},
@@ -687,9 +668,9 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
{toggleError && <p role="alert" className="text-sm text-destructive">{toggleError}</p>}
{/* Page tabs */}
<div className="flex items-center gap-1 border-b">
<div role="group" aria-label="Раздел GRE" className="flex items-center gap-1 border-b">
{(["tunnels", "pools"] as PageTab[]).map((tab) => (
<button key={tab} onClick={() => setPageTab(tab)}
<button key={tab} aria-pressed={pageTab === tab} onClick={() => setPageTab(tab)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px ${pageTab === tab ? "border-foreground text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"}`}>
{tab === "tunnels" ? "Туннели" : "IP-пулы"}
</button>
@@ -735,13 +716,10 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
{/* ── IP Pools ── */}
{pageTab === "pools" && (
<DataPageCard>
<DataPageToolbarFrame className="justify-between">
<span className="text-sm text-muted-foreground">{displayPools.length} пула</span>
<Button size="sm" variant="outline" onClick={() => { setPForm(defaultPoolForm); setPoolOpen(true) }}>
<PlusIcon className="size-4" />
Добавить пул
</Button>
</DataPageToolbarFrame>
{isLive ? (
<div role="status" className="p-5 text-sm text-muted-foreground">API GRE возвращает интерфейсы и назначенные IP. Каталог пулов, CIDR и свободная ёмкость не предоставляются; управление пулами здесь недоступно.</div>
) : <>
<DataPageToolbarFrame className="justify-between"><span className="text-sm text-muted-foreground">Моковый каталог · только просмотр</span></DataPageToolbarFrame>
<GrePoolsDataGrid pools={displayPools} />
<div className="border-t px-5 py-4">
@@ -767,6 +745,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
})}
</div>
</div>
</>}
</DataPageCard>
)}
@@ -884,13 +863,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
<div className="flex flex-col gap-4">
<SectionTitle>Внутренний IP</SectionTitle>
<FormField label="IP-пул" hint="Необязательно — внутренний IP можно указать вручную">
<select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="" disabled>Выбрать пул…</option>
{displayPools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) — свободно {p.total - p.allocated} блоков</option>)}
</select>
</FormField>
{!isLive && <p className="text-xs text-muted-foreground">Моковые пулы доступны только для просмотра.</p>}
<div className="grid grid-cols-2 gap-3">
<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)} />
@@ -984,49 +957,6 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
</SheetContent>
</Sheet>
{/* ══ Sheet: Add Pool ════════════════════════════════════════════════════ */}
<Sheet open={poolOpen} onOpenChange={setPoolOpen}>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Новый IP-пул</SheetTitle>
<SheetDescription>Пул адресов для назначения внутренних IP GRE-туннелям</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Параметры пула</SectionTitle>
<FormField label="Имя пула" required hint="Например pool-gre-office или pool-gre-dc2">
<Input className="font-mono" placeholder="pool-gre-core" value={pForm.name}
onChange={(e) => setPForm((f) => ({ ...f, name: e.target.value }))} />
</FormField>
<FormField label="Диапазон CIDR" required hint="Блок, из которого будут нарезаться /30 на каждый туннель">
<Input className="font-mono" placeholder="10.200.0.0/24" value={pForm.cidr}
onChange={(e) => setPForm((f) => ({ ...f, cidr: e.target.value }))} />
</FormField>
<FormField label="Назначение / Комментарий">
<Input placeholder="Ядровые межузловые туннели" value={pForm.comment}
onChange={(e) => setPForm((f) => ({ ...f, comment: e.target.value }))} />
</FormField>
{pForm.cidr && /\/\d+$/.test(pForm.cidr) && (() => {
const prefix = parseInt(pForm.cidr.split("/")[1] ?? "0")
const blocks = prefix <= 30 ? Math.pow(2, 30 - prefix) : 0
return blocks > 0 ? (
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm">
<p className="text-muted-foreground">
Доступно <span className="font-semibold text-foreground font-mono">{blocks}</span> блоков /30
{" "}= до <span className="font-semibold text-foreground font-mono">{blocks}</span> туннелей
</p>
</div>
) : null
})()}
</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={() => setPoolOpen(false)}>Создать пул</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<ConfigHistorySheet
open={historyOpen}
onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
+1 -48
View File
@@ -9,23 +9,14 @@ import {
} from "@tanstack/react-table"
import type { GrePool } from "@/lib/data"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { LayersIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react"
import { LayersIcon } from "lucide-react"
interface GrePoolsDataGridProps {
pools: GrePool[]
@@ -130,44 +121,6 @@ function GrePoolsDataGrid({ pools }: GrePoolsDataGridProps) {
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
enableSorting: false,
cell: () => (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="outline"
size="icon"
className={cn(
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
"opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100",
"data-popup-open:opacity-100",
)}
>
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem>
<PencilIcon className="size-4" /> Редактировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" /> Удалить пул
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
size: 56,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[],
)
+8 -1
View File
@@ -25,12 +25,19 @@
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.61 — IPsec/GRE/VXLAN, Firewall и операции Filters |
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.62 — IPsec/GRE/VXLAN, Firewall и операции Filters |
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 9.62 — возможности API GRE-пулов (2026-09-30)
- Удалён фиктивный live-каталог из poolId интерфейсов: он не содержит CIDR/ёмкость, но раньше изображал полное заполнение и свободные блоки. Live KPI пулов теперь неизвестен с подписью «нет данных API», раздел объясняет отсутствие каталога/управления. Инвентаризация настоящих /ip/pool и её API не изобретены.
- Убраны кнопки создания пула, пустые меню изменения/удаления и selector пула, который не входил в GRE write-body. Mock-каталог остаётся только для просмотра с явной подписью; настройки локального IP остаются доступными. Кнопки разделов получили aria-pressed и имя группы.
- Семь E2E прошли: числовая сортировка/поиск 12 туннелей, отсутствие подмены неизвестных пулов пустыми, mock-каталог/no writes и три формы. TypeScript/lint чистые.
- Далее — desktop/mobile GRE/VXLAN и профильная регрессия. Расширение backend настоящим управлением адресными пулами — отдельный функциональный объём, текущий UX честно отражает доступные API. Локальный коммит без push.
## Блок 9.61 — поддерживаемые параметры GRE/PSK и .rsc (2026-09-30)
- GRE .rsc вынесен в generateGreRsc: quoted RouterOS strings/PSK, нормализованные комментарии, локальный IP только при наличии, без придуманных удалённых IP/маршрутов и дополнительных manual IPsec peer/policy/proposal поверх автоматического ipsec-secret. Параметры сверены с https://help.mikrotik.com/docs/spaces/ROS/pages/24805531/GRE и правилами Scripting. FastPath в снимке не меняется автоматически; несовместимый IPsec/FastPath подписан в комментарии.
+8
View File
@@ -353,3 +353,11 @@ Live-копирование читает актуальный целевой rul
- Unit экспорта проверил строки/инъекцию новой команды/optional IP/no duplicate IPsec; шесть E2E PSK/create/edit/export прошли. TypeScript/lint чистые. RouterOS применение и реальное снятие PSK остаются внешней приёмкой.
- Далее — достоверное отображение live-пулов и возможности API, затем layout и профильная регрессия GRE/VXLAN. Локальный коммит без push.
## Блок 9.62 — возможности API GRE-пулов (2026-09-30)
- Удалён фиктивный live-каталог из poolId интерфейсов: он не содержит CIDR/ёмкость, но раньше изображал полное заполнение и свободные блоки. Live KPI пулов теперь неизвестен с подписью «нет данных API», раздел объясняет отсутствие каталога/управления. Инвентаризация настоящих /ip/pool и её API не изобретены.
- Убраны кнопки создания пула, пустые меню изменения/удаления и selector пула, который не входил в GRE write-body. Mock-каталог остаётся только для просмотра с явной подписью; настройки локального IP остаются доступными. Кнопки разделов получили aria-pressed и имя группы.
- Семь E2E прошли: числовая сортировка/поиск 12 туннелей, отсутствие подмены неизвестных пулов пустыми, mock-каталог/no writes и три формы. TypeScript/lint чистые.
- Далее — desktop/mobile GRE/VXLAN и профильная регрессия. Расширение backend настоящим управлением адресными пулами — отдельный функциональный объём, текущий UX честно отражает доступные API. Локальный коммит без push.
+21
View File
@@ -0,0 +1,21 @@
import { test, expect } from "@playwright/test"
test("GRE: mock-пулы только для просмотра без фиктивного создания", async ({ page }) => {
let writes = 0
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
if (r.request().method() !== "GET") writes++
return r.fulfill({ json: new URL(r.request().url()).pathname === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/settings")
await page.getByRole("button", { name: "Моковые", exact: true }).click()
await page.getByRole("link", { name: /^GRE/ }).click()
await expect(page.getByRole("group", { name: "Свежесть GRE" })).toContainText("не получены с RouterOS")
await page.getByRole("button", { name: "IP-пулы", exact: true }).click()
await expect(page.getByText("Моковый каталог · только просмотр", { exact: true })).toBeVisible()
expect(await page.locator("tr[data-row-id]").count()).toBeGreaterThan(0)
await expect(page.getByRole("button", { name: "Добавить пул", exact: true })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Создать пул", exact: true })).toHaveCount(0)
await expect(page.getByRole("menuitem", { name: "Удалить пул", exact: true })).toHaveCount(0)
expect(writes).toBe(0)
})
+4 -7
View File
@@ -40,7 +40,7 @@ test("IPsec: 12 клиентов, сортировка, выбор удален
await expect(rows).toHaveCount(1)
})
test("GRE: 12 туннелей и пулов, числовая сортировка и поиск", async ({ page }) => {
test("GRE: 12 туннелей, числовая сортировка и достоверность пулов", async ({ page }) => {
const errors: string[] = []
page.on("pageerror", e => errors.push(e.message))
await setup(page, "/gre")
@@ -52,11 +52,8 @@ test("GRE: 12 туннелей и пулов, числовая сортиров
await expect(rows).toHaveCount(1)
await page.getByPlaceholder("Поиск по имени, IP…").fill("")
await page.getByRole("button", { name: "IP-пулы", exact: true }).click()
await expect(rows).toHaveCount(12)
await page.getByRole("button", { name: "Имя пула", exact: true }).click()
await expect(rows.first()).toContainText("pool-1")
await page.getByRole("button", { name: "Имя пула", exact: true }).click()
await expect(rows.first()).toContainText("pool-12")
await expect(rows).toHaveCount(0)
await expect(page.getByRole("status").filter({ hasText: "Каталог пулов, CIDR и свободная ёмкость не предоставляются" })).toBeVisible()
expect(errors).toEqual([])
})
@@ -66,5 +63,5 @@ test("Пустые ответы IPsec и GRE", async ({ page }) => {
await page.goto("/gre")
await expect(page.getByText("Нет GRE-туннелей", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "IP-пулы", exact: true }).click()
await expect(page.getByText("Нет IP-пулов", { exact: true })).toBeVisible()
await expect(page.getByRole("status").filter({ hasText: "Каталог пулов, CIDR и свободная ёмкость не предоставляются" })).toBeVisible()
})