diff --git a/app/(main)/communities/page.tsx b/app/(main)/communities/page.tsx index 898bb1b4..4605621d 100644 --- a/app/(main)/communities/page.tsx +++ b/app/(main)/communities/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useMemo } from "react" +import { useEffect, useRef, useState, useMemo } from "react" import { PageHeader } from "@/components/page-header" import { DataPageCard } from "@/components/data-page-card" import { @@ -12,6 +12,7 @@ import { import { Frame, FramePanel } from "@/components/reui/frame" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { OpsPanel } from "@/components/ops-panel" +import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { @@ -105,6 +106,13 @@ function CommunitiesContent() { const [search, setSearch] = useState("") const [typeFilter, setTypeFilter] = useState("all") const [copied, setCopied] = useState(null) + const [copying, setCopying] = useState(false) + const [copyError, setCopyError] = useState(null) + const copyRequest = useRef({ active: true, pending: false, revision: 0, timer: null as ReturnType | null }) + useEffect(() => { + const state = copyRequest.current; state.active = true + return () => { state.active = false; state.revision++; if (state.timer) clearTimeout(state.timer) } + }, []) const [selectedId, setSelectedId] = useState(null) const listData = useMemo((): Community[] => { @@ -124,10 +132,24 @@ function CommunitiesContent() { }) }, [search, typeFilter, listData]) - const handleCopy = (value: string) => { - navigator.clipboard.writeText(value).catch(() => {}) - setCopied(value) - setTimeout(() => setCopied(null), 1500) + const handleCopy = async (value: string) => { + const state = copyRequest.current + if (!state.active || state.pending) return + state.pending = true; const revision = ++state.revision + if (state.timer) clearTimeout(state.timer) + const current = () => state.active && state.revision === revision + setCopying(true); setCopied(null); setCopyError(null) + try { + await navigator.clipboard.writeText(value) + if (!current()) return + setCopied(value) + state.timer = setTimeout(() => { if (current()) setCopied(null) }, 1500) + } catch { + if (current()) setCopyError("Браузер не разрешил запись в буфер обмена. Повторите копирование или выделите значение вручную.") + } finally { + state.pending = false + if (current()) setCopying(false) + } } const filterName = (id: string) => filters.find(f => f.id === id)?.name ?? id @@ -145,6 +167,9 @@ function CommunitiesContent() {
+ {copyError && Community не скопировано{copyError}} + {copying &&

Копирование community…

} + {copied &&

Community {copied} скопировано

} setSelectedId(row.id)} onCopy={handleCopy} /> : } @@ -267,7 +293,7 @@ function CommunitiesContent() { )}
- diff --git a/components/data-grids/communities-data-grid.tsx b/components/data-grids/communities-data-grid.tsx index 0a67d80b..d32c8f9b 100644 --- a/components/data-grids/communities-data-grid.tsx +++ b/components/data-grids/communities-data-grid.tsx @@ -66,6 +66,7 @@ const ACTION_COLOR: Record = { interface CommunitiesDataGridProps { communities: CommunityRow[] selectedId?: string | null + copying?: boolean copiedValue?: string | null onSelect: (community: CommunityRow) => void onCopy: (value: string) => void @@ -75,6 +76,7 @@ function CommunitiesDataGrid({ communities, selectedId, copiedValue, + copying = false, onSelect, onCopy, }: CommunitiesDataGridProps) { @@ -106,6 +108,7 @@ function CommunitiesDataGrid({ }} className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:bg-muted" aria-label={`Скопировать community: ${c.value}`} + disabled={copying} > {copiedValue === c.value ? ( @@ -230,7 +233,7 @@ function CommunitiesDataGrid({ }, }, ], - [copiedValue, onCopy, onSelect], + [copiedValue, copying, onCopy, onSelect], ) const rowSelection = useMemo( diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index bff84c86..b3bacd28 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -4,7 +4,7 @@ ## Актуальная точка продолжения — 2026-10-04 -Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. 12.2.5 принят локально: реальные ping samples/unknown/ось RTT/Recharts. 12.2.6 принят локально: strict GRE/WireGuard/VXLAN и device failures. 12.2.7 принят локально: strict Internet path snapshot/fallback. 12.2.8 принят локально: подтверждение полного backend сбора. 12.2.9 принят локально: метрики участков и наблюдаемый WAN без эвристической подмены. 12.3.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. 12.3.18 принят локально:users1000/поиск всего списка/детали и ошибка refresh/32 browser/build. 12.3.19 принят локально:backups1000/все мобильные действия/возврат focus/39+4 browser/build. 12.3.20 принят локально:независимые speed GET/cache/freshness/58+4 browser/build. 12.3.21 принят локально:strict history DTO/null rates от DB до UI/60+5 browser/unit4/builds. 12.4.3 принят локально:users/backups delta context36 на a558412. 12.3.22 принят локально:shared speed-probes DTO/context8/60+6+47 browser/unit5/builds. 12.3.23 принят локально:strict write DTO/atomic replacement/confirmed scheduler warning/65 browser/HTTP DI JWT/builds. 12.3.24 принят локально:OSPF routes1000/50 DOM/page20/cache/320px/22+1 browser/build. 12.3.25 принят локально:BFD1000/50 DOM/cache/320px/12 browser/build. 12.4.4 принят локально:context213/25 файлов и unit36 прошли на5b2a291. 12.4.5 принят локально:main1110/171 прошли на21859be. 12.3.26 принят локально:strict atomic Ping write/подтверждение каталога и сохранения/история удаления/62 browser/context11/DI6/builds. 12.3.27 принят локально:WireGuard zero/missing counters/UI/backend mapper/29 browser/unit/builds. 12.3.28 принят локально:WireGuard interfaces1000/50 DOM/pinning/actions/cache/29+1 browser/build. 12.3.29 принят локально:WG peers1000/50 DOM/pinning/точный delete target/29+3 browser/build. 12.3.30 принят локально:WG detail1000/50 DOM/320px/клавиатура/31+1 browser/build. 12.3.31 принят локально:WG handshake без ложного online/unknown/7+1 browser/build. 12.3.32 принят локально:WG URL search/tab/status/reload/история/34+4 browser/unit/build. 12.3.33 принят локально:WG strict read/cache/freshness/unknown/40+7 browser/context8/build. 12.3.34 принят локально:VXLAN1000/50 DOM/pinning/export/22 browser/build. 12.3.35 принят локально:GRE1000/50 DOM/pinning/20+1 browser/build. 12.3.36 карта15 оставшихся источников обновлена. 12.3.37 top50 traffic/пустой локальный фильтр/перенос tabs принят в описанных границах. 12.3.38 Communities1000/50 DOM/19 browser/build принят локально. Следующий блок12.3.39: clipboard confirmation; затем recursive groups/endpoints и полная main/context регрессия.12.5 отдельно. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются. +Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. 12.2.5 принят локально: реальные ping samples/unknown/ось RTT/Recharts. 12.2.6 принят локально: strict GRE/WireGuard/VXLAN и device failures. 12.2.7 принят локально: strict Internet path snapshot/fallback. 12.2.8 принят локально: подтверждение полного backend сбора. 12.2.9 принят локально: метрики участков и наблюдаемый WAN без эвристической подмены. 12.3.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. 12.3.18 принят локально:users1000/поиск всего списка/детали и ошибка refresh/32 browser/build. 12.3.19 принят локально:backups1000/все мобильные действия/возврат focus/39+4 browser/build. 12.3.20 принят локально:независимые speed GET/cache/freshness/58+4 browser/build. 12.3.21 принят локально:strict history DTO/null rates от DB до UI/60+5 browser/unit4/builds. 12.4.3 принят локально:users/backups delta context36 на a558412. 12.3.22 принят локально:shared speed-probes DTO/context8/60+6+47 browser/unit5/builds. 12.3.23 принят локально:strict write DTO/atomic replacement/confirmed scheduler warning/65 browser/HTTP DI JWT/builds. 12.3.24 принят локально:OSPF routes1000/50 DOM/page20/cache/320px/22+1 browser/build. 12.3.25 принят локально:BFD1000/50 DOM/cache/320px/12 browser/build. 12.4.4 принят локально:context213/25 файлов и unit36 прошли на5b2a291. 12.4.5 принят локально:main1110/171 прошли на21859be. 12.3.26 принят локально:strict atomic Ping write/подтверждение каталога и сохранения/история удаления/62 browser/context11/DI6/builds. 12.3.27 принят локально:WireGuard zero/missing counters/UI/backend mapper/29 browser/unit/builds. 12.3.28 принят локально:WireGuard interfaces1000/50 DOM/pinning/actions/cache/29+1 browser/build. 12.3.29 принят локально:WG peers1000/50 DOM/pinning/точный delete target/29+3 browser/build. 12.3.30 принят локально:WG detail1000/50 DOM/320px/клавиатура/31+1 browser/build. 12.3.31 принят локально:WG handshake без ложного online/unknown/7+1 browser/build. 12.3.32 принят локально:WG URL search/tab/status/reload/история/34+4 browser/unit/build. 12.3.33 принят локально:WG strict read/cache/freshness/unknown/40+7 browser/context8/build. 12.3.34 принят локально:VXLAN1000/50 DOM/pinning/export/22 browser/build. 12.3.35 принят локально:GRE1000/50 DOM/pinning/20+1 browser/build. 12.3.36 карта15 оставшихся источников обновлена. 12.3.37 top50 traffic/пустой локальный фильтр/перенос tabs принят в описанных границах. 12.3.38 Communities1000/50 DOM/19 browser/build принят локально. 12.3.39 clipboard ack/error/retry/unmount/5 browser/build принят локально. Следующий блок12.3.40: recursive groups/endpoints и полная main/context регрессия.12.5 отдельно. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются. Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md). diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 0853630c..81b21497 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -31,6 +31,14 @@ | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе;12.1/12.2.1–12.2.10 и12.3.1–12.3.5 и12.3.7–12.3.12 приняты в описанных локальных границах;12.4.1 main регрессия и профильный повтор записаны;12.4.2 context205 и unit28 прошли;12.3.13–12.3.25 приняты локально в описанных границах;12.4.4 context213 и12.4.5 main1110 прошли, 12.3.26–12.3.33 приняты локально; 12.3.34 VXLAN и12.3.35 GRE tunnels приняты; остаются network grids/общая регрессия после delta/внешняя приёмка | +## 12.3.39 — подтверждение копирования Communities (2026-10-04) + +Clipboard success показывается только после resolved writeText; reject/недоступный API сохраняет значение и показывает ReUI Alert с повтором/ручным копированием. Pending блокирует повторный вызов и кнопки списка/деталей; активная операция и timer защищены mount/revision guard, источник страницы уже ключуется sourceKey. Уведомления pending/success доступны через role=status. + +5/5 browser (4 clipboard cases и large Communities повтор) прошли: отсутствие преждевременной галочки, двойное нажатие, reject→retry, поздний успех/ошибка после unmount. TypeScript без incremental/ESLint и production frontend build прошли. Clipboard подменён promise в браузере; системные разрешения и смена backend/mode именно во время clipboard отдельно не проверены. Не утверждаем, что уже начатая запись в системный clipboard отменяется при unmount. + +Следующий блок12.3.40: recursive routes1000 групп и1000 endpoints одной группы, pagination/URL expansion/global sort/refresh/cache/320px.14 manualPagination:true остаются. Общая main/context регрессия и внешняя12.5 отдельно. Коммит локальный без push. + ## 12.3.38 — большой каталог Communities (2026-10-04) ReUI pagination50, global sort/search, сохранение страницы при refresh и clamp при уменьшении списка. Закреплено значение community; копирование и явная кнопка деталей имеют44px/доступные подписи. Выбор по ID не привязан к странице и сохраняется при сортировке/обновлении; удалённая запись закрывает детали. Кнопка деталей работает с клавиатуры. Права/источник/отсутствующие prefix counts сохранены. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index af006eac..5d36ea71 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -497,3 +497,12 @@ ReUI pagination50, global sort/search, сохранение страницы п Осталось14 manualPagination:true specialized grids. Обнаружено прежнее ложное подтверждение clipboard: handleCopy ставит copied до завершения writeText и игнорирует reject. Следующий блок12.3.39: исправить ack/error/pending копирования и поздние ответы при смене источника; затем12.3.40 recursive groups+endpoints. Общая main/context регрессия после delta и внешняя12.5 отдельно. Коммит локальный без push. + +## 12.3.39 — подтверждение копирования Communities (2026-10-04) + +Clipboard success показывается только после resolved writeText; reject/недоступный API сохраняет значение и показывает ReUI Alert с повтором/ручным копированием. Pending блокирует повторный вызов и кнопки списка/деталей; активная операция и timer защищены mount/revision guard, источник страницы уже ключуется sourceKey. Уведомления pending/success доступны через role=status. + +5/5 browser (4 clipboard cases и large Communities повтор) прошли: отсутствие преждевременной галочки, двойное нажатие, reject→retry, поздний успех/ошибка после unmount. TypeScript без incremental/ESLint и production frontend build прошли. Clipboard подменён promise в браузере; системные разрешения и смена backend/mode именно во время clipboard отдельно не проверены. Не утверждаем, что уже начатая запись в системный clipboard отменяется при unmount. + +Следующий блок12.3.40: recursive routes1000 групп и1000 endpoints одной группы, pagination/URL expansion/global sort/refresh/cache/320px.14 manualPagination:true остаются. Общая main/context регрессия и внешняя12.5 отдельно. Коммит локальный без push. + diff --git a/tests/e2e/communities-copy.spec.ts b/tests/e2e/communities-copy.spec.ts new file mode 100644 index 00000000..d6daeeb6 --- /dev/null +++ b/tests/e2e/communities-copy.spec.ts @@ -0,0 +1,38 @@ +import { test, expect, type Page } from "@playwright/test" +import { evoCatalog, evoSettings } from "./fixtures/evobgp" +const community = { id: "copy", value: "64512:7", name: "Копируемое значение", description: "", type: "custom", filterIds: [], serverCount: 0, prefixCount: null, action: "permit", enabled: true } +async function setup(page: Page) { + await page.addInitScript(() => { + const state = window as unknown as { attempts: number; finishCopy: (failed: boolean) => void } + state.attempts = 0 + Object.defineProperty(navigator, "clipboard", { value: { writeText: () => { state.attempts++; return new Promise((resolve, reject) => { state.finishCopy = failed => failed ? reject(new Error("Clipboard denied")) : resolve() }) } } }) + }) + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { const p = new URL(r.request().url()).pathname; return r.fulfill({ json: p === "/api/auth/config" ? { required: false } : p === "/api/evobgp/settings" ? evoSettings : p === "/api/evobgp/catalog" ? { ...evoCatalog, communities: [community] } : {} }) }) + await page.goto("/communities") + return page.getByRole("button", { name: "Скопировать community: 64512:7", exact: true }) +} +for (const failed of [false, true]) test("Communities copy: pending/ack/retry " + failed, async ({ page }) => { + const button = await setup(page) + await button.evaluate(el => { (el as HTMLButtonElement).click(); (el as HTMLButtonElement).click() }) + await expect(button).toBeDisabled(); await expect(page.getByText("Копирование community…", { exact: true })).toBeVisible() + expect(await page.evaluate(() => (window as unknown as { attempts: number }).attempts)).toBe(1) + await expect(page.getByText("Community 64512:7 скопировано", { exact: true })).toHaveCount(0) + await page.evaluate(f => (window as unknown as { finishCopy: (failed: boolean) => void }).finishCopy(f), failed) + await expect(button).toBeEnabled() + if (failed) { + await expect(page.getByRole("alert").filter({ hasText: "Community не скопировано" })).toBeVisible() + await expect(page.getByText("Community 64512:7 скопировано", { exact: true })).toHaveCount(0) + await button.click(); await page.evaluate(() => (window as unknown as { finishCopy: (failed: boolean) => void }).finishCopy(false)) + await expect(page.getByText("Community не скопировано", { exact: true })).toHaveCount(0) + } + await expect(page.getByText("Community 64512:7 скопировано", { exact: true })).toBeVisible() +}) +for (const failed of [false, true]) test("Communities copy: late result after unmount " + failed, async ({ page }) => { + const button = await setup(page); await button.click(); await expect(button).toBeDisabled() + await page.locator('a[href="/asns"]').first().click(); await expect(page).toHaveURL(/asns$/) + await page.evaluate(f => (window as unknown as { finishCopy: (failed: boolean) => void }).finishCopy(f), failed) + await page.locator('a[href="/communities"]').first().click(); await expect(button).toBeEnabled() + await expect(page.getByText("Community 64512:7 скопировано", { exact: true })).toHaveCount(0) + await expect(page.getByText("Community не скопировано", { exact: true })).toHaveCount(0) +})