fix(ui): убрать неподтверждённые данные и действия GRE-пулов
This commit is contained in:
1 parent
337d14abdf
commit
fc3d588cec
6 files changed
+55
-139
No files matched your search
+13
-83
@@ -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) }}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
@@ -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 подписан в комментарии.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
Reference in new issue
Block a user