fix(ui): исправить доступность оболочки и проверку состояния сервера
No files matched your search
@@ -467,7 +467,7 @@ const SEVERITY_META: Record<AlertSeverity, { label: string; dot: string; badge:
|
|||||||
label: "Критическое",
|
label: "Критическое",
|
||||||
dot: "bg-red-500",
|
dot: "bg-red-500",
|
||||||
badge: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400",
|
badge: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400",
|
||||||
chip: "border-red-400 bg-red-500/10 text-red-600 dark:text-red-400",
|
chip: "border-red-400 bg-red-500/10 text-red-700 dark:text-red-400",
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
label: "Предупреждение",
|
label: "Предупреждение",
|
||||||
@@ -726,7 +726,7 @@ function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled,i
|
|||||||
!rule.enabled && "opacity-55",
|
!rule.enabled && "opacity-55",
|
||||||
)}>
|
)}>
|
||||||
{/* toggle */}
|
{/* toggle */}
|
||||||
<FormToggle ariaLabel={"Включить правило "+rule.name} checked={rule.enabled} onChange={v => onToggle(rule.id, v)} disabled={interactionsDisabled||(isLive&&rule.type==="bgp-prefix"&&!rule.enabled)} />
|
<FormToggle className="h-6" ariaLabel={"Включить правило "+rule.name} checked={rule.enabled} onChange={v => onToggle(rule.id, v)} disabled={interactionsDisabled||(isLive&&rule.type==="bgp-prefix"&&!rule.enabled)} />
|
||||||
|
|
||||||
{/* severity dot */}
|
{/* severity dot */}
|
||||||
<SeverityDot severity={rule.severity} />
|
<SeverityDot severity={rule.severity} />
|
||||||
@@ -746,7 +746,7 @@ function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled,i
|
|||||||
{rule.targets.length > 1 ? `${rule.targets.length} объектов` : rule.target}
|
{rule.targets.length > 1 ? `${rule.targets.length} объектов` : rule.target}
|
||||||
</span>
|
</span>
|
||||||
{rule.groupId ? (
|
{rule.groupId ? (
|
||||||
<span className="text-muted-foreground/80"> · группа</span>
|
<span className="text-muted-foreground"> · группа</span>
|
||||||
) : null}
|
) : null}
|
||||||
{" · "}{rule.condition}
|
{" · "}{rule.condition}
|
||||||
</p>
|
</p>
|
||||||
@@ -968,7 +968,7 @@ function ServerObjectCardPicker({
|
|||||||
{loc ? (
|
{loc ? (
|
||||||
<p className="text-[10px] text-muted-foreground/90 truncate">{loc}</p>
|
<p className="text-[10px] text-muted-foreground/90 truncate">{loc}</p>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="text-[9px] text-muted-foreground/80">
|
<p className="text-[9px] text-muted-foreground">
|
||||||
{SERVER_ROLES_RU[s.type] ?? s.type}
|
{SERVER_ROLES_RU[s.type] ?? s.type}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1122,7 +1122,7 @@ function GreTunnelObjectCardPicker({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{r.comment ? (
|
{r.comment ? (
|
||||||
<p className="text-[9px] text-muted-foreground/80 line-clamp-2 mt-1 pl-5 border-t border-border/30 pt-1 leading-snug">
|
<p className="text-[9px] text-muted-foreground line-clamp-2 mt-1 pl-5 border-t border-border/30 pt-1 leading-snug">
|
||||||
{r.comment}
|
{r.comment}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -2559,7 +2559,7 @@ function AlertsContent() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"text-xs px-3 py-1.5 rounded-full border font-medium transition-colors",
|
"text-xs px-3 py-1.5 rounded-full border font-medium transition-colors",
|
||||||
onlyActive
|
onlyActive
|
||||||
? "border-emerald-400 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
? "border-emerald-400 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
|
||||||
: "border-border bg-muted/40 text-muted-foreground hover:bg-muted",
|
: "border-border bg-muted/40 text-muted-foreground hover:bg-muted",
|
||||||
)}>
|
)}>
|
||||||
Активных: {active}
|
Активных: {active}
|
||||||
@@ -2569,8 +2569,8 @@ function AlertsContent() {
|
|||||||
<span className={cn(
|
<span className={cn(
|
||||||
"ml-auto text-xs px-3 py-1.5 rounded-full border flex items-center gap-1.5 font-medium",
|
"ml-auto text-xs px-3 py-1.5 rounded-full border flex items-center gap-1.5 font-medium",
|
||||||
tg.connected
|
tg.connected
|
||||||
? "border-emerald-400 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
? "border-emerald-400 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
|
||||||
: "border-red-400 bg-red-500/10 text-red-600 dark:text-red-400",
|
: "border-red-400 bg-red-500/10 text-red-700 dark:text-red-400",
|
||||||
)}>
|
)}>
|
||||||
<span className={cn("size-1.5 rounded-full", tg.connected ? "bg-emerald-500" : "bg-red-500")} />
|
<span className={cn("size-1.5 rounded-full", tg.connected ? "bg-emerald-500" : "bg-red-500")} />
|
||||||
{tg.connected ? "Telegram: Параметры заполнены" : "Telegram: Не настроен"}
|
{tg.connected ? "Telegram: Параметры заполнены" : "Telegram: Не настроен"}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function MainLayout({ children }: { children: ReactNode }) {
|
|||||||
К содержимому
|
К содержимому
|
||||||
</a>
|
</a>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset id="main-content" className="h-svh overflow-hidden">
|
<SidebarInset tabIndex={-1} id="main-content" className="h-svh overflow-hidden">
|
||||||
{children}
|
{children}
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export default function SettingsPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{backendStatus === true && (
|
{backendStatus === true && (
|
||||||
<span className="flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
|
<span className="flex items-center gap-1.5 text-xs text-emerald-700 dark:text-emerald-400">
|
||||||
<CheckIcon className="size-3.5" />Доступен
|
<CheckIcon className="size-3.5" />Доступен
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -121,7 +121,7 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
{/* hint when live but backend unreachable */}
|
{/* hint when live but backend unreachable */}
|
||||||
{mode === "live" && backendStatus === false && (
|
{mode === "live" && backendStatus === false && (
|
||||||
<div className="flex items-start gap-2 py-3 text-xs text-amber-600 dark:text-amber-400">
|
<div className="flex items-start gap-2 py-3 text-xs text-amber-700 dark:text-amber-400">
|
||||||
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
|
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
|
||||||
<span>Сервер недоступен. Последние полученные данные сохраняются; новые запросы могут завершаться ошибкой.</span>
|
<span>Сервер недоступен. Последние полученные данные сохраняются; новые запросы могут завершаться ошибкой.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { cn } from "@/lib/utils"
|
|||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { filters, pingProbes, servers } from "@/lib/data"
|
import { filters, pingProbes, servers } from "@/lib/data"
|
||||||
import type { SidebarCountsDto } from "@/lib/sidebar-badges"
|
import type { SidebarCountsDto } from "@/lib/sidebar-badges"
|
||||||
import { resolveApiUrl } from "@/shared/api/http-client"
|
import { getBackendHealth } from "@/shared/api/backend-health"
|
||||||
|
|
||||||
import {can,ensureAuthConfig} from "@/lib/auth"
|
import {can,ensureAuthConfig} from "@/lib/auth"
|
||||||
import {getSidebarCounts} from "@/shared/api/sidebar-counts"
|
import {getSidebarCounts} from "@/shared/api/sidebar-counts"
|
||||||
@@ -66,9 +66,9 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
|
|||||||
>
|
>
|
||||||
{metric.icon}
|
{metric.icon}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
|
<span className="text-muted-foreground min-w-0 break-words text-[11px]">{metric.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
|
<span className="text-foreground shrink-0 text-xs font-semibold tabular-nums">
|
||||||
{metric.value}
|
{metric.value}
|
||||||
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
|
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -92,7 +92,7 @@ export function SystemMonitorPopover() {
|
|||||||
controller?.abort();controller=new AbortController();const request=++revision,signal=controller.signal
|
controller?.abort();controller=new AbortController();const request=++revision,signal=controller.signal
|
||||||
const publish=(patch:Partial<NonNullable<typeof snapshot>>)=>{if(cancelled||request!==revision)return;setSnapshot(previous=>({...((previous?.source===source?previous:null)??{source,healthOk:null,counts:null,receivedAt:null,countsError:null}),...patch}))}
|
const publish=(patch:Partial<NonNullable<typeof snapshot>>)=>{if(cancelled||request!==revision)return;setSnapshot(previous=>({...((previous?.source===source?previous:null)??{source,healthOk:null,counts:null,receivedAt:null,countsError:null}),...patch}))}
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
(async()=>{try{const response=await fetch(resolveApiUrl(backendUrl,"/health"),{signal:AbortSignal.any([signal,AbortSignal.timeout(3000)]),cache:"no-store"});const raw=await response.json();publish({healthOk:response.ok&&raw?.status==="ok"})}catch{publish({healthOk:false})}})(),
|
(async()=>{try{await getBackendHealth(backendUrl,signal);publish({healthOk:true})}catch{publish({healthOk:false})}})(),
|
||||||
(async()=>{await ensureAuthConfig();if(cancelled||request!==revision)return;if(!can("mm:dashboard:read")){publish({counts:null,countsError:"Нет доступа к сводке"});return}try{const result=await getSidebarCounts(backendUrl,AbortSignal.any([signal,AbortSignal.timeout(30_000)]));if(can("mm:dashboard:read"))publish({counts:result,receivedAt:new Date().toISOString(),countsError:null})}catch{publish({countsError:"Сводка не обновлена; показан последний успешный снимок, если он есть."})}})(),
|
(async()=>{await ensureAuthConfig();if(cancelled||request!==revision)return;if(!can("mm:dashboard:read")){publish({counts:null,countsError:"Нет доступа к сводке"});return}try{const result=await getSidebarCounts(backendUrl,AbortSignal.any([signal,AbortSignal.timeout(30_000)]));if(can("mm:dashboard:read"))publish({counts:result,receivedAt:new Date().toISOString(),countsError:null})}catch{publish({countsError:"Сводка не обновлена; показан последний успешный снимок, если он есть."})}})(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@@ -183,7 +183,7 @@ export function SystemMonitorPopover() {
|
|||||||
<Badge
|
<Badge
|
||||||
variant={spiking ? "destructive-light" : apiOk?"success-light":"outline"}
|
variant={spiking ? "destructive-light" : apiOk?"success-light":"outline"}
|
||||||
size="xs"
|
size="xs"
|
||||||
className="h-4 px-1.5 text-[10px]"
|
className={cn("h-4 px-1.5 text-[10px]",apiOk&&"text-emerald-700 dark:text-emerald-400",spiking&&"text-red-700 dark:text-red-400")}
|
||||||
>
|
>
|
||||||
{spiking?"Нет связи":apiOk?"API доступен":"Нет данных"}
|
{spiking?"Нет связи":apiOk?"API доступен":"Нет данных"}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## Актуальная точка продолжения — 2026-10-03
|
## Актуальная точка продолжения — 2026-10-03
|
||||||
|
|
||||||
Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. Следующий блок 12.1: общий shell — автоматический аудит доступности/клавиатура/фокус на ПК и320px в обеих темах, согласовать health popover с shared health; затем strict/partial источники dashboard, производительность и сквозная регрессия. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются.
|
Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. Следующий блок12.2.1: strict BGP catalog, partial и nullable counters на dashboard; затем остальные источники, производительность и сквозная регрессия. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются.
|
||||||
|
|
||||||
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
|
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
|
||||||
|
|
||||||
|
|||||||
@@ -29,13 +29,21 @@
|
|||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе;12.1 принят локально, ограничения ниже |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 12.1 — оболочка: контраст, клавиатура и health (2026-10-03)
|
||||||
|
|
||||||
|
Подтверждённые нарушения автоматического axe-аудита исправлены на уровнях потребителей: доступный фокус main для skip link; высота toggle правила не менее24px; контраст API badge, подписей групп/Telegram и статуса backend. В открытом системном мониторе значения используют text-foreground, цвет остаётся у значка/полосы; длинная подпись переносится. Установленные ReUI/Base UI primitives и глобальная тема не изменены. Health использует shared getBackendHealth, подтверждённый legacy ok:true согласован с выбором источника; неподтверждённый ответ не становится успехом. axe-core4.11.3 закреплён прямой dev dependency.
|
||||||
|
|
||||||
|
Проверки:20 browser сценариев —12 audit комбинаций alerts/settings/releases×320/1440×light/dark,4 открытых monitor/menu, клавиатура skip/apps/monitor с возвратом фокуса, legacy health,2 неподтверждённых health; violations нет. После переноса подписи дополнительно4 открытых слоя.14 профильных layout/settings/release проверок:13 прошли сразу, release fixture встретила transient Windows file lock; исходные bytes восстановлены, ограниченный retry только при записи fixture, повтор всех3 releases прошёл. Typecheck и ESLint изменённых компонентов/тестов прошли. ReUI checklist: сохранены композиции, API props проверены по установленным исходникам; имена/роли, реальные состояния, scroll/security проверены в границах сценариев. Снимки shell-monitor-light/dark-320/1440 добавлены.
|
||||||
|
|
||||||
|
Границы: audit охватывает стабильные описанные состояния, не все модалки/формы и не ручную работу screen reader; нет заявления полного соответствия WCAG. Production build и общая регрессия предусмотрены в12.4. Далее12.2.1: строгий BGP catalog на дашборде, partial и nullable counters без подмены нулями; затем остальные источники.
|
||||||
|
|
||||||
## Блок 11Б.7 — сводная локальная приёмка администрирования (2026-10-03)
|
## Блок 11Б.7 — сводная локальная приёмка администрирования (2026-10-03)
|
||||||
|
|
||||||
- 189 browser, 68 context/source, 61 unit/contracts/API и7 named backend profiles прошли. После финальной композиции Alerts дополнительно16 reader/targets/queue и25 form/preview/layout,14 Alerts source прошли. Frontend/contracts production build и backend build прошли; финальная frontend build после композиции тоже прошла. Lint/typecheck профильных изменений прошли.
|
- 189 browser, 68 context/source, 61 unit/contracts/API и7 named backend profiles прошли. После финальной композиции Alerts дополнительно16 reader/targets/queue и25 form/preview/layout,14 Alerts source прошли. Frontend/contracts production build и backend build прошли; финальная frontend build после композиции тоже прошла. Lint/typecheck профильных изменений прошли.
|
||||||
|
|||||||
@@ -15,3 +15,16 @@
|
|||||||
Клавиатурный сценарий должен предоставлять эквивалент действий мышью ([W3C SC 2.1.1](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html)); программные имена/роли/состояния проверяются отдельно ([W3C SC 4.1.2](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html)). Проверка reflow включает 320 CSS px; широкие таблицы проверяются как отдельные области прокрутки, а не как разрешение переполнения страницы ([W3C SC 1.4.10](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html)).
|
Клавиатурный сценарий должен предоставлять эквивалент действий мышью ([W3C SC 2.1.1](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html)); программные имена/роли/состояния проверяются отдельно ([W3C SC 4.1.2](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html)). Проверка reflow включает 320 CSS px; широкие таблицы проверяются как отдельные области прокрутки, а не как разрешение переполнения страницы ([W3C SC 1.4.10](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html)).
|
||||||
|
|
||||||
Внешние ограничения 11Б сохраняются: migrations0008/0009, PG isolation/CAS/rollback, реальный restore/UDP workers, RouterOS, Telegram, MaxMind/RIPE, Portal/Evolution, CI/Gitea. Processing не разрешается повторять без доказуемой остановки всех отправителей; timeout или checkbox не являются таким доказательством. BGP-prefix evaluator отсутствует и честно обозначен UI; отдельного live GRE расписания нет. Производственный выпуск не разрешён одним успешным browser/DI прогоном.
|
Внешние ограничения 11Б сохраняются: migrations0008/0009, PG isolation/CAS/rollback, реальный restore/UDP workers, RouterOS, Telegram, MaxMind/RIPE, Portal/Evolution, CI/Gitea. Processing не разрешается повторять без доказуемой остановки всех отправителей; timeout или checkbox не являются таким доказательством. BGP-prefix evaluator отсутствует и честно обозначен UI; отдельного live GRE расписания нет. Производственный выпуск не разрешён одним успешным browser/DI прогоном.
|
||||||
|
|
||||||
|
## 12.1 — оболочка: контраст, клавиатура и health (2026-10-03)
|
||||||
|
|
||||||
|
Подтверждённые нарушения автоматического axe-аудита исправлены на уровнях потребителей: доступный фокус main для skip link; высота toggle правила не менее24px; контраст API badge, подписей групп/Telegram и статуса backend. В открытом системном мониторе значения используют text-foreground, цвет остаётся у значка/полосы; длинная подпись переносится. Установленные ReUI/Base UI primitives и глобальная тема не изменены. Health использует shared getBackendHealth, подтверждённый legacy ok:true согласован с выбором источника; неподтверждённый ответ не становится успехом. axe-core4.11.3 закреплён прямой dev dependency.
|
||||||
|
|
||||||
|
Проверки:20 browser сценариев —12 audit комбинаций alerts/settings/releases×320/1440×light/dark,4 открытых monitor/menu, клавиатура skip/apps/monitor с возвратом фокуса, legacy health,2 неподтверждённых health; violations нет. После переноса подписи дополнительно4 открытых слоя.14 профильных layout/settings/release проверок:13 прошли сразу, release fixture встретила transient Windows file lock; исходные bytes восстановлены, ограниченный retry только при записи fixture, повтор всех3 releases прошёл. Typecheck и ESLint изменённых компонентов/тестов прошли. ReUI checklist: сохранены композиции, API props проверены по установленным исходникам; имена/роли, реальные состояния, scroll/security проверены в границах сценариев. Снимки shell-monitor-light/dark-320/1440 добавлены.
|
||||||
|
|
||||||
|
Границы: audit охватывает стабильные описанные состояния, не все модалки/формы и не ручную работу screen reader; нет заявления полного соответствия WCAG. Production build и общая регрессия предусмотрены в12.4. Далее12.2.1: строгий BGP catalog на дашборде, partial и nullable counters без подмены нулями; затем остальные источники.
|
||||||
|
|
||||||
|
| Снимок | ПК | Телефон |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Светлый монитор | [1440](ux-ui/screens/shell-monitor-light-1440.png) | [320](ux-ui/screens/shell-monitor-light-320.png) |
|
||||||
|
| Тёмный монитор | [1440](ux-ui/screens/shell-monitor-dark-1440.png) | [320](ux-ui/screens/shell-monitor-dark-320.png) |
|
||||||
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -43,6 +43,7 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"axe-core": "4.11.3",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.4",
|
"eslint-config-next": "16.2.4",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"axe-core": "4.11.3",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.4",
|
"eslint-config-next": "16.2.4",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import {test,expect,type Page} from "@playwright/test"
|
||||||
|
import axe from "axe-core"
|
||||||
|
import {alertsWithRule,emptyOutbox} from "./fixtures/alerts"
|
||||||
|
import {evoSettings,evoCatalog} from "./fixtures/evobgp"
|
||||||
|
async function setup(page:Page,theme:string){
|
||||||
|
await page.addInitScript(value=>localStorage.setItem("rl-theme",value),theme)
|
||||||
|
await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}))
|
||||||
|
await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;const body=path==="/api/auth/config"?{required:false}:path==="/api/alerts"?alertsWithRule:path==="/api/alerts/outbox"?emptyOutbox:path==="/api/evobgp/settings"?evoSettings:path==="/api/evobgp/catalog"?evoCatalog:path==="/api/sidebar-counts"?{servers:0,filterRules:0,uptimeProbes:0,uptimeSpeedProbes:0,monitoringItems:0,recursiveRoutes:0}:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:path==="/api/servers"||path==="/api/bgp/sessions"?[]:null;return body!==null?r.fulfill({json:body}):r.fulfill({status:503,json:{error:"Fixture unavailable"}})})
|
||||||
|
}
|
||||||
|
async function settleAnimations(page:Page){await page.evaluate(async()=>{await Promise.allSettled(document.getAnimations().filter(a=>a.effect?.getComputedTiming().iterations!==Infinity).map(a=>a.finished))})}
|
||||||
|
for(const theme of ["dark","light"])for(const width of [320,1440])for(const path of ["/alerts","/settings","/releases"])test("Automated WCAG tagged audit/"+theme+"/"+width+path,async({page},info)=>{
|
||||||
|
await setup(page,theme);await page.setViewportSize({width,height:900});await page.goto(path)
|
||||||
|
if(path==="/alerts")await expect(page.getByRole("textbox",{name:"Поиск правил",exact:true})).toBeVisible()
|
||||||
|
else if(path==="/settings"){await expect(page.getByRole("button",{name:"Тёмная",exact:true})).toBeEnabled();await expect(page.getByRole("button",{name:"Скачать",exact:true})).toBeEnabled()}
|
||||||
|
else await expect(page.getByText("Текущая версия",{exact:true})).toBeVisible()
|
||||||
|
await settleAnimations(page);await page.addScriptTag({content:axe.source})
|
||||||
|
const result=await page.evaluate(async()=>{const engine=(window as unknown as {axe:typeof axe}).axe;return engine.run({include:["#main-content",'[aria-label="Основная навигация"]']},{runOnly:{type:"tag",values:["wcag2a","wcag2aa","wcag21a","wcag21aa","wcag22aa"]}})})
|
||||||
|
await info.attach("axe-results",{body:JSON.stringify(result,null,2),contentType:"application/json"})
|
||||||
|
expect(result.violations.map(v=>({id:v.id,impact:v.impact,nodes:v.nodes.map(n=>({target:n.target,html:n.html,summary:n.failureSummary}))}))).toEqual([])
|
||||||
|
})
|
||||||
|
test("Shell keyboard skip link moves focus to main and menus restore focus",async({page})=>{
|
||||||
|
await setup(page,"dark");await page.goto("/releases");await expect(page.getByText("Текущая версия",{exact:true})).toBeVisible();await page.keyboard.press("Tab");const skip=page.getByRole("link",{name:"К содержимому",exact:true});await expect(skip).toBeFocused();await page.keyboard.press("Enter");await expect(page.locator("#main-content")).toBeFocused()
|
||||||
|
const apps=page.getByRole("button",{name:"Приложения",exact:true});await apps.focus();await page.keyboard.press("Enter");await expect(page.getByRole("menu")).toBeVisible();await page.keyboard.press("Escape");await expect(apps).toBeFocused()
|
||||||
|
const monitor=page.getByRole("button",{name:"Монитор системы",exact:true});await monitor.focus();await page.keyboard.press("Enter");await expect(page.locator('[data-slot="popover-content"]')).toBeVisible();await page.keyboard.press("Escape");await expect(monitor).toBeFocused()
|
||||||
|
})
|
||||||
|
test("System monitor recognizes confirmed legacy health consistently",async({page})=>{await setup(page,"dark");await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.goto("/releases");await expect(page.getByRole("button",{name:"Монитор системы",exact:true})).toContainText("API доступен")})
|
||||||
|
|
||||||
|
for(const theme of ["dark","light"])for(const width of [320,1440])test("Open shell layers audit/"+theme+"/"+width,async({page},info)=>{
|
||||||
|
await setup(page,theme);await page.setViewportSize({width,height:900});await page.goto("/releases");await expect(page.getByRole("button",{name:"Монитор системы",exact:true})).toContainText("API доступен");await page.getByRole("button",{name:"Монитор системы",exact:true}).click();await expect(page.locator('[data-slot="popover-content"]')).toBeVisible();await page.addScriptTag({content:axe.source});
|
||||||
|
const result=await page.evaluate(async()=>{const engine=(window as unknown as {axe:typeof axe}).axe;return engine.run('[data-slot="popover-content"]',{runOnly:{type:"tag",values:["wcag2a","wcag2aa","wcag21a","wcag21aa","wcag22aa"]}})});await info.attach("axe-open-monitor",{body:JSON.stringify(result,null,2),contentType:"application/json"});expect(result.violations.map(v=>({id:v.id,nodes:v.nodes.map(n=>({target:n.target,html:n.html,summary:n.failureSummary}))}))).toEqual([]);
|
||||||
|
await page.screenshot({path:"docs/ux-ui/screens/shell-monitor-"+theme+"-"+width+".png",animations:"disabled"});await page.keyboard.press("Escape");await page.getByRole("button",{name:"Приложения",exact:true}).click();await expect(page.getByRole("menu")).toBeVisible();await settleAnimations(page);const menu=await page.evaluate(async()=>{const engine=(window as unknown as {axe:typeof axe}).axe;return engine.run('[role="menu"]',{runOnly:{type:"tag",values:["wcag2a","wcag2aa","wcag21a","wcag21aa","wcag22aa"]}})});expect(menu.violations.map(v=>({id:v.id,nodes:v.nodes.map(n=>({target:n.target,html:n.html,summary:n.failureSummary}))}))).toEqual([]);
|
||||||
|
})
|
||||||
|
for(const theme of ["dark","light"])test("Unconfirmed health remains unavailable/"+theme,async({page})=>{await setup(page,theme);await page.route("**/health",r=>r.fulfill({json:{ok:false,status:"unknown"}}));await page.goto("/releases");await expect(page.getByRole("button",{name:"Монитор системы",exact:true})).toContainText("Нет связи")})
|
||||||
@@ -15,18 +15,19 @@ for(const width of [320,1440])test("Releases local build is not presented as pub
|
|||||||
// Server component reads a local manifest. Restore the original bytes even on assertion failure.
|
// Server component reads a local manifest. Restore the original bytes even on assertion failure.
|
||||||
test("Releases malformed metadata is unknown and long confirmed entries remain readable on mobile",async({page})=>{
|
test("Releases malformed metadata is unknown and long confirmed entries remain readable on mobile",async({page})=>{
|
||||||
const {readFileSync,writeFileSync}=await import("node:fs");const file="public/release-manifest.json";const original=readFileSync(file)
|
const {readFileSync,writeFileSync}=await import("node:fs");const file="public/release-manifest.json";const original=readFileSync(file)
|
||||||
|
const writeManifest=async(content:string|Uint8Array)=>{for(let attempt=0;;attempt++){try{writeFileSync(file,content);return}catch(error){if(attempt>=10||!error||typeof error!=="object"||!("code" in error)||!["UNKNOWN","EBUSY","EPERM","EACCES"].includes(String(error.code)))throw error;await new Promise(resolve=>setTimeout(resolve,100))}}}
|
||||||
try{
|
try{
|
||||||
await page.setViewportSize({width:320,height:900})
|
await page.setViewportSize({width:320,height:900})
|
||||||
await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:[]}))
|
await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:[]}))
|
||||||
writeFileSync(file,JSON.stringify({version:"1.0.0",commits:null}));await page.goto("/releases")
|
await writeManifest(JSON.stringify({version:"1.0.0",commits:null}));await page.goto("/releases")
|
||||||
await expect(page.getByRole("alert").filter({hasText:"Метаданные релиза недоступны или повреждены"})).toBeVisible()
|
await expect(page.getByRole("alert").filter({hasText:"Метаданные релиза недоступны или повреждены"})).toBeVisible()
|
||||||
await expect(page.getByText("Нет подтверждённых метаданных.",{exact:true})).toBeVisible()
|
await expect(page.getByText("Нет подтверждённых метаданных.",{exact:true})).toBeVisible()
|
||||||
await expect(page.getByRole("link",{name:"Открыть релиз",exact:true})).toHaveCount(0)
|
await expect(page.getByRole("link",{name:"Открыть релиз",exact:true})).toHaveCount(0)
|
||||||
const sha="a".repeat(40);const subject="fix(ui): "+"ДлинноеОписание".repeat(20)
|
const sha="a".repeat(40);const subject="fix(ui): "+"ДлинноеОписание".repeat(20)
|
||||||
writeFileSync(file,JSON.stringify({version:"1.0.0",tag:"v1.0.0",publishedAt:"2026-10-03T00:00:00Z",releaseUrl:"https://example.test/releases/tag/v1.0.0",commits:[{sha,shortSha:sha.slice(0,7),subject,author:"Fixture",type:"fix"}]}));await page.reload()
|
await writeManifest(JSON.stringify({version:"1.0.0",tag:"v1.0.0",publishedAt:"2026-10-03T00:00:00Z",releaseUrl:"https://example.test/releases/tag/v1.0.0",commits:[{sha,shortSha:sha.slice(0,7),subject,author:"Fixture",type:"fix"}]}));await page.reload()
|
||||||
await expect(page.getByText("Коммиты манифеста v1.0.0: 1.",{exact:true})).toBeVisible()
|
await expect(page.getByText("Коммиты манифеста v1.0.0: 1.",{exact:true})).toBeVisible()
|
||||||
await expect(page.getByText(subject.replace("fix(ui): ",""),{exact:true})).toBeVisible()
|
await expect(page.getByText(subject.replace("fix(ui): ",""),{exact:true})).toBeVisible()
|
||||||
const link=page.getByRole("link",{name:"Открыть релиз",exact:true});await expect(link).toHaveAttribute("href","https://example.test/releases/tag/v1.0.0");await expect(link).toHaveAttribute("rel","noopener noreferrer")
|
const link=page.getByRole("link",{name:"Открыть релиз",exact:true});await expect(link).toHaveAttribute("href","https://example.test/releases/tag/v1.0.0");await expect(link).toHaveAttribute("rel","noopener noreferrer")
|
||||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true)
|
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true)
|
||||||
}finally{writeFileSync(file,original)}
|
}finally{await writeManifest(original)}
|
||||||
})
|
})
|
||||||