fix(ui): проверить доступность и адаптивность сертификатов
No files matched your search
@@ -4,7 +4,7 @@ import { Suspense, useMemo, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { readCertificatesUrl, patchCertificatesUrl, type CertificateStatusFilter } from "@/lib/certificates-url-state"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
||||
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
||||
import { routerCertificates, servers as mockServers } from "@/lib/data"
|
||||
import type { Server } from "@/lib/data"
|
||||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
||||
@@ -77,10 +77,10 @@ function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
|
||||
|
||||
function CertPartAlertExpired({ expired }: { expired: CertificateView[] }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-red-500/5 border border-red-500/20 px-4 py-3 text-sm">
|
||||
<AlertCircleIcon className="size-5 text-red-500 shrink-0 mt-0.5" />
|
||||
<div className="flex items-start gap-3 rounded-lg bg-destructive/5 border border-destructive/20 px-4 py-3 text-sm">
|
||||
<AlertCircleIcon className="size-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-red-600 dark:text-red-400">
|
||||
<p className="font-medium text-destructive">
|
||||
{expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
@@ -93,10 +93,10 @@ function CertPartAlertExpired({ expired }: { expired: CertificateView[] }) {
|
||||
|
||||
function CertPartAlertExpiring({ expiring }: { expiring: CertificateView[] }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm">
|
||||
<AlertTriangleIcon className="size-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="flex items-start gap-3 rounded-lg bg-warning/5 border border-warning/20 px-4 py-3 text-sm">
|
||||
<AlertTriangleIcon className="size-5 text-warning shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-amber-600 dark:text-amber-400">
|
||||
<p className="font-medium text-warning">
|
||||
{expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
@@ -112,11 +112,13 @@ function CertPartKpi({
|
||||
expiring,
|
||||
expired,
|
||||
complete,
|
||||
totalKnown,
|
||||
}: {
|
||||
displayCerts: CertificateView[]
|
||||
expiring: CertificateView[]
|
||||
expired: CertificateView[]
|
||||
complete: boolean
|
||||
totalKnown: boolean
|
||||
}) {
|
||||
return (
|
||||
<KpiStatGrid
|
||||
@@ -125,7 +127,7 @@ function CertPartKpi({
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего",
|
||||
value: complete ? displayCerts.length : "—",
|
||||
value: totalKnown ? displayCerts.length : "—",
|
||||
icon: <ShieldCheckIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
@@ -508,27 +510,27 @@ function CertificatesContent() {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-5 min-w-0">
|
||||
{!isLive && <p role="status" className="text-xs text-muted-foreground">Моковые данные</p>}
|
||||
{urlError && <div role="alert" aria-label="Контекст сертификатов">{urlError} <Button variant="outline" size="sm" onClick={() => writeContext({ server: null, status: null, q: null }, true)}>Сбросить контекст</Button></div>}
|
||||
{!urlError && selectedServerId !== ALL_SERVERS_ID && (isLive ? catalog.hasState && !snapshots[selectedServerId] : !mockServers.some(server => server.id === selectedServerId)) && <p role="alert">Выбранный сервер недоступен в каталоге сертификатов. Выберите другой сервер.</p>}
|
||||
{isLive && backendStatus === false && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/5 px-4 py-3 text-sm text-warning">
|
||||
Backend недоступен — live-операции отключены.
|
||||
</div>
|
||||
)}
|
||||
{loadError && isLive && (
|
||||
<div role="alert" aria-label="Каталог сертификатов" className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
<div role="alert" aria-label="Каталог сертификатов" className="[overflow-wrap:anywhere] rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{catalog.hasState ? "Последние данные сохранены. " : "Сертификаты пока неизвестны. "}{loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLive && serverError && <p role="alert" aria-label="Серверы сертификатов">{catalog.hasServers ? "Последние серверы сохранены. " : "Каталог серверов пока неизвестен. "}{serverError}</p>}
|
||||
{isLive && serverError && <p role="alert" aria-label="Серверы сертификатов" className="[overflow-wrap:anywhere]">{catalog.hasServers ? "Последние серверы сохранены. " : "Каталог серверов пока неизвестен. "}{serverError}</p>}
|
||||
{isLive && !canRead && <p role="status">Нет права mm:certificates:read.</p>}
|
||||
{isLive && !canReadServers && <p role="status">Нет права mm:servers:read. Выбор устройства для выпуска недоступен.</p>}
|
||||
{isLive && catalog.collectedAt && <p className="text-xs text-muted-foreground">Последнее чтение каталога: {new Date(catalog.collectedAt).toLocaleString("ru-RU")}</p>}
|
||||
{isLive && Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId).map(snapshot => <div key={snapshot.latest.serverId}>
|
||||
{snapshot.latest.error && <p role="alert" aria-label={"Сертификаты сервера " + snapshot.latest.serverId}>{snapshot.latest.serverName}: {snapshot.latest.error}. {snapshot.confirmed ? "Последние сертификаты сохранены." : "Сертификаты пока неизвестны."}</p>}
|
||||
{snapshot.latest.error && <p role="alert" aria-label={"Сертификаты сервера " + snapshot.latest.serverId} className="[overflow-wrap:anywhere]">{snapshot.latest.serverName}: {snapshot.latest.error}. {snapshot.confirmed ? "Последние сертификаты сохранены." : "Сертификаты пока неизвестны."}</p>}
|
||||
<p className="text-xs text-muted-foreground">{snapshot.latest.serverName}: {snapshot.confirmed ? "Подтверждённые данные от " + new Date(snapshot.confirmed.collectedAt).toLocaleString("ru-RU") : "Успешного чтения нет"}</p>
|
||||
</div>)}
|
||||
|
||||
@@ -540,7 +542,7 @@ function CertificatesContent() {
|
||||
)}
|
||||
|
||||
{!classificationsKnown && <p role="status" className="text-xs text-muted-foreground">Сводка неполная: часть устройств, статусов или сроков неизвестна. Таблица показывает доступные снимки.</p>}
|
||||
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} complete={classificationsKnown} />
|
||||
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} complete={classificationsKnown} totalKnown={scopeKnown} />
|
||||
|
||||
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} canRead={canRead} canWrite={canWrite} />
|
||||
|
||||
@@ -551,17 +553,17 @@ function CertificatesContent() {
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по имени, CN, эмитенту…"
|
||||
segmented={{
|
||||
value: statusFilter,
|
||||
onChange: setStatusFilter,
|
||||
options: [
|
||||
filterControl={<div role="group" aria-label="Статус сертификатов" className="w-full sm:w-auto max-w-full overflow-x-auto"><SegmentedControl className="whitespace-nowrap"
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
options={[
|
||||
{ value: "all", label: "Все" },
|
||||
{ value: "valid", label: "Действующие" },
|
||||
{ value: "expired", label: "Истёкшие" },
|
||||
{ value: "revoked", label: "Отозванные" },
|
||||
{ value: "unknown", label: "Неизвестные" },
|
||||
],
|
||||
}}
|
||||
]}
|
||||
/></div>}
|
||||
countLabel={isLive && !catalog.hasState ? "Количество неизвестно" : `${filtered.length} строк в снимках`}
|
||||
/>
|
||||
{urlError ? <p role="status" className="p-5">Исправьте контекст ссылки</p> : isLive && scopedCerts.length === 0 && !scopeKnown ? <p role="status" className="p-5">{loading ? "Загрузка сертификатов…" : "Список сертификатов пока неизвестен"}</p> : <CertificatesDataGrid
|
||||
@@ -578,7 +580,7 @@ function CertificatesContent() {
|
||||
</ServerRailLayout>
|
||||
|
||||
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
||||
<SheetContent side="right" className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg min-w-0 flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>Выпуск сертификата</SheetTitle>
|
||||
<SheetDescription>
|
||||
@@ -586,7 +588,7 @@ function CertificatesContent() {
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="px-6 pt-3 text-sm">
|
||||
<div className="px-6 pt-3 text-sm min-w-0 [overflow-wrap:anywhere]">
|
||||
{(issue.error || issue.storageError) && <p role="alert" aria-label="Выпуск сертификата">{issue.error || issue.storageError}</p>}
|
||||
{issue.checkpoint && <p className="text-xs text-muted-foreground">Закрытие формы не отменяет серверную задачу. Её можно проверить повторно.</p>}
|
||||
{issue.checkpoint && <p role="status">Задача {issue.checkpoint.request.requestId}: {issue.checkpoint.job ? ({ queued: "В очереди", running: "Выполняется", done: "Завершена", failed: "Ошибка" }[issue.checkpoint.job.status]) + " · " + issue.checkpoint.job.step : issue.checkpoint.accepted ? "Принята; результат пока неизвестен" : "Принятие запроса пока не подтверждено"}. Сервер {issue.checkpoint.request.serverId}, сертификат {issue.checkpoint.request.certName}.</p>}
|
||||
|
||||
@@ -4,13 +4,13 @@ import type { CertificateView } from "@/lib/certificate-view"
|
||||
|
||||
function CertificateExpandedDetail({ cert }: { cert: CertificateView }) {
|
||||
return (
|
||||
<div className="px-10 pb-4 grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs border-t border-border/50 pt-3">
|
||||
<div className="min-w-0 [overflow-wrap:anywhere] px-5 sm:px-10 pb-4 grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs border-t border-border/50 pt-3">
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Размер ключа</p>
|
||||
<p className="font-mono font-medium">{cert.keySize === null ? "Неизвестен" : cert.keySize + " бит"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Действителен с</p>
|
||||
<p className="text-muted-foreground mb-1">Начало срока</p>
|
||||
<p className="font-mono">{cert.validFrom ?? "Дата неизвестна"}</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -31,6 +31,8 @@ function CertificateExpandedDetail({ cert }: { cert: CertificateView }) {
|
||||
{cert.trusted === null ? "Неизвестно" : cert.trusted ? "Да (доверенный)" : "Нет (не доверенный)"}
|
||||
</p>
|
||||
</div>
|
||||
{"rawValidFrom" in cert && !cert.validFrom && cert.rawValidFrom && <p className="col-span-2 sm:col-span-4 text-muted-foreground">Начало срока не удалось определить. Значение RouterOS: <span className="font-mono">{cert.rawValidFrom}</span></p>}
|
||||
{"rawValidUntil" in cert && !cert.validUntil && cert.rawValidUntil && <p className="col-span-2 sm:col-span-4 text-muted-foreground">Окончание срока не удалось определить. Значение RouterOS: <span className="font-mono">{cert.rawValidUntil}</span></p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,3 +38,10 @@ test("expired and positive observed days stay distinct", () => {
|
||||
assert.match(renderToStaticMarkup(<CertDaysCell cert={{ ...cert, daysLeft: -3 }} />), /Истёк 3д назад/)
|
||||
assert.match(renderToStaticMarkup(<CertDaysCell cert={{ ...cert, daysLeft: 40 }} />), /40д осталось/)
|
||||
})
|
||||
|
||||
test("unparsed RouterOS dates remain explicit raw values without claiming valid dates", () => {
|
||||
const details = renderToStaticMarkup(<CertificateExpandedDetail cert={{ ...cert, rawValidFrom: "oct/01/2026 12:00:00", rawValidUntil: "oct/01/2027 12:00:00" }} />)
|
||||
assert.match(details, /Начало срока не удалось определить/)
|
||||
assert.match(details, /Окончание срока не удалось определить/)
|
||||
assert.ok(details.includes("oct/01/2026 12:00:00"))
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import type { Server } from "@/lib/data"
|
||||
import type { CertificateView } from "@/lib/certificate-view"
|
||||
import { CertDaysCell } from "@/components/data-grids/certificate-days-cell"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
@@ -40,27 +41,27 @@ const STATUS_CONFIG: Record<
|
||||
> = {
|
||||
unknown: {
|
||||
label: "Неизвестен",
|
||||
icon: <CircleHelpIcon className="size-4 text-muted-foreground" />,
|
||||
icon: <CircleHelpIcon aria-hidden="true" className="size-4 text-muted-foreground" />,
|
||||
badge: "bg-muted text-muted-foreground border-border",
|
||||
row: "",
|
||||
},
|
||||
valid: {
|
||||
label: "Действителен",
|
||||
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />,
|
||||
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
||||
icon: <BadgeCheckIcon aria-hidden="true" className="size-4 text-success" />,
|
||||
badge: "bg-success/10 text-success border-success/20",
|
||||
row: "",
|
||||
},
|
||||
expired: {
|
||||
label: "Истёк",
|
||||
icon: <ShieldOffIcon className="size-4 text-red-500" />,
|
||||
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||
row: "bg-red-500/5",
|
||||
icon: <ShieldOffIcon aria-hidden="true" className="size-4 text-destructive" />,
|
||||
badge: "bg-destructive/10 text-destructive border-destructive/20",
|
||||
row: "bg-destructive/5",
|
||||
},
|
||||
revoked: {
|
||||
label: "Отозван",
|
||||
icon: <ShieldAlertIcon className="size-4 text-amber-500" />,
|
||||
badge: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||||
row: "bg-amber-500/5",
|
||||
icon: <ShieldAlertIcon aria-hidden="true" className="size-4 text-warning" />,
|
||||
badge: "bg-warning/10 text-warning border-warning/20",
|
||||
row: "bg-warning/5",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -87,11 +88,13 @@ function CertificatesDataGrid({ certificates, serverMap, isLoading, emptyTitle =
|
||||
const expanded = row.getIsExpanded()
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<Button type="button" variant="ghost" size="icon" className="size-11 sm:size-8 shrink-0" aria-label={"Детали сертификата " + cert.name} aria-expanded={expanded} onClick={event => { event.stopPropagation(); row.toggleExpanded() }}>
|
||||
{expanded ? (
|
||||
<ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<ChevronDownIcon aria-hidden="true" className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
|
||||
<ChevronRightIcon aria-hidden="true" className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="shrink-0">{cfg.icon}</span>
|
||||
|
||||
@@ -226,4 +226,4 @@ REST запрос → ожидание → подтверждённый output /
|
||||
| Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) |
|
||||
| Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) |
|
||||
|
||||
Актуальная точка продолжения: 11А.22 — визуальная и профильная приёмка сертификатов, затем резервные копии. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты.
|
||||
Актуальная точка продолжения: 11А.23 — достоверность результата issue runner, затем окончательная приёмка сертификатов и резервные копии. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты.
|
||||
@@ -27,7 +27,7 @@
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.21.4; пользователи/контейнеры приняты локально, сертификаты: state/URL/API/renew/ACME готовы; далее 11А.22 |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.22; пользователи/контейнеры приняты локально, сертификаты: state/URL/API/renew/ACME готовы; далее 11А.23 |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
@@ -36,6 +36,15 @@
|
||||
|
||||
|
||||
|
||||
## Блок 11А.22 — визуальная и профильная приёмка UI сертификатов (2026-10-01)
|
||||
|
||||
- Состояния таблицы/предупреждений используют semantic success/warning/destructive. Раскрытие деталей имеет native кнопку с именем, aria-expanded и клавиатурой; исходные нераспознанные даты RouterOS показываются явно без подмены нормализованной датой.
|
||||
- Общее количество остаётся известным при неизвестных статусах/сроках, отдельные классификации показывают —. Мобильный фильтр сохраняет подписи в горизонтальном контейнере; Sheet использует full width через data-side вариант, footer переносится. Vendor/theme не изменены.
|
||||
- 9 PNG (обзор/детали/форма × ПК/390/320) сохранены и просмотрены. Layout проверки включают отсутствие горизонтального overflow страницы/формы, клавиатурное раскрытие, поиск и возврат фокуса после Escape.
|
||||
- Профильная регрессия: 48 main E2E + 16 context E2E, 26 frontend unit, backend test:certificates, frontend/backend production build, TypeScript/профильный ESLint и ReUI checklist прошли. Реальные PostgreSQL/ACME/RouterOS/S3 не проверялись.
|
||||
- UI сертификатов принят в этих локальных границах. При сверке runner найден отдельный дефект: ошибка appendEvent после успешного выпуска попадает в общий catch и записывает failed; fire-and-forget onStep может перезаписать terminal шаг.
|
||||
- **Следующий блок 11А.23:** отделить события/ошибки метаданных runner от результата реальной операции, упорядочить onStep записи, защитить terminal от повторного запуска и cleanup lock при setup ошибке; fault unit/backend checks. Затем метаданные JSON snapshot/окончательная приёмка сертификатов и резервные копии.
|
||||
|
||||
## Блок 11А.21.4 — сохраняемая задача выпуска на странице (2026-10-01)
|
||||
|
||||
- До POST создаётся requestId и immutable цель, записывается checkpoint sessionStorage по backend URL. Невозможность первоначальной записи не отправляет выпуск. Reload восстанавливает ту же задачу; моковый режим не показывает live checkpoint. Повреждённая запись блокирует новый выпуск.
|
||||
|
||||
@@ -272,3 +272,20 @@
|
||||
- При unmount отменяются POST/poll/задержки; поздние успех/ошибка не меняют другой backend/mode и не показывают старый toast.
|
||||
- 3 checkpoint + 5 poll unit, 7 issue E2E, 8 source/context E2E, TypeScript/ESLint и production frontend прошли. Реальный PostgreSQL/ACME/RouterOS выпуск не проверен; pending job восстановление после перезапуска backend требует отдельной инфраструктурной приёмки.
|
||||
- Следующий блок 11А.22: семантические токены, клавиатура/ПК/390/320, снимки и профильная регрессия сертификатов. Импорт/экспорт приватного ключа остаются явно недоступны; безопасный JSON снимок каталога можно добавить отдельно. Затем резервные копии и 11Б/12.
|
||||
|
||||
## Блок 11А.22 — визуальная и профильная приёмка UI сертификатов (2026-10-01)
|
||||
|
||||
- Состояния таблицы/предупреждений используют semantic success/warning/destructive. Раскрытие деталей имеет native кнопку с именем, aria-expanded и клавиатурой; исходные нераспознанные даты RouterOS показываются явно без подмены нормализованной датой.
|
||||
- Общее количество остаётся известным при неизвестных статусах/сроках, отдельные классификации показывают —. Мобильный фильтр сохраняет подписи в горизонтальном контейнере; Sheet использует full width через data-side вариант, footer переносится. Vendor/theme не изменены.
|
||||
- 9 PNG (обзор/детали/форма × ПК/390/320) сохранены и просмотрены. Layout проверки включают отсутствие горизонтального overflow страницы/формы, клавиатурное раскрытие, поиск и возврат фокуса после Escape.
|
||||
- Профильная регрессия: 48 main E2E + 16 context E2E, 26 frontend unit, backend test:certificates, frontend/backend production build, TypeScript/профильный ESLint и ReUI checklist прошли. Реальные PostgreSQL/ACME/RouterOS/S3 не проверялись.
|
||||
- UI сертификатов принят в этих локальных границах. При сверке runner найден отдельный дефект: ошибка appendEvent после успешного выпуска попадает в общий catch и записывает failed; fire-and-forget onStep может перезаписать terminal шаг.
|
||||
- **Следующий блок 11А.23:** отделить события/ошибки метаданных runner от результата реальной операции, упорядочить onStep записи, защитить terminal от повторного запуска и cleanup lock при setup ошибке; fault unit/backend checks. Затем метаданные JSON snapshot/окончательная приёмка сертификатов и резервные копии.
|
||||
|
||||
### Экраны сертификатов
|
||||
|
||||
| Область | Проверенные изображения | Схема |
|
||||
| --- | --- | --- |
|
||||
| Обзор | [ПК](screens/certificates-overview-desktop.png), [390](screens/certificates-overview-mobile.png), [320](screens/certificates-overview-narrow.png) | Свежесть → известный итог / неизвестные классификации → настройки → таблица |
|
||||
| Детали | [ПК](screens/certificates-details-desktop.png), [390](screens/certificates-details-mobile.png), [320](screens/certificates-details-narrow.png) | Фильтр → native раскрытие → поля и unknown → исходные даты |
|
||||
| Выпуск | [ПК](screens/certificates-issue-desktop.png), [390](screens/certificates-issue-mobile.png), [320](screens/certificates-issue-narrow.png) | Цель → проверка → requestId → та же задача → подтверждение API |
|
||||
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1,24 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
import { certificateRoutes, openIssue } from "./fixtures/certificate-issue"
|
||||
import { certificate, certificateState } from "./fixtures/certificates"
|
||||
for (const [name, width] of [["desktop", 1440], ["mobile", 390], ["narrow", 320]] as const) test("Сертификаты: клавиатура и адаптивность " + name, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 }); await certificateRoutes(page)
|
||||
const long = { ...certificate, name: "certificate-with-a-very-long-name-for-narrow-screens", commonName: "very-long-subdomain-for-responsive-layout.example.test", sans: ["very-long-alternative-domain-for-responsive-layout.example.test"] }
|
||||
await page.route("**/api/certificates/state", r => r.fulfill({ json: certificateState([long]) }))
|
||||
await page.goto("/certificates"); await expect(page.getByText(long.name, { exact: true })).toBeVisible()
|
||||
const summary = page.getByLabel("Сводка сертификатов", { exact: true }); await expect(summary).toContainText("1")
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true)
|
||||
const detail = page.getByRole("button", { name: "Детали сертификата " + long.name, exact: true }); await detail.focus(); await page.keyboard.press("Enter")
|
||||
await expect(detail).toHaveAttribute("aria-expanded", "true"); await expect(page.getByText("Доверие", { exact: true })).toBeVisible()
|
||||
await page.screenshot({ path: "docs/ux-ui/screens/certificates-details-" + name + ".png", fullPage: true })
|
||||
await page.evaluate(() => { for (const element of document.querySelectorAll<HTMLElement>("*")) if (element.scrollTop > 0) element.scrollTop = 0 })
|
||||
await page.screenshot({ path: "docs/ux-ui/screens/certificates-overview-" + name + ".png", fullPage: true })
|
||||
await page.keyboard.press("Enter"); await expect(detail).toHaveAttribute("aria-expanded", "false")
|
||||
const search = page.getByRole("textbox", { name: "Поиск по имени, CN, эмитенту…" }); await search.fill("нет-совпадений"); await expect(page.getByText("Нет совпадений", { exact: true })).toBeVisible(); await search.fill("")
|
||||
const dialog = await openIssue(page)
|
||||
await expect.poll(async () => { const box = await dialog.boundingBox(); return !!box && box.x >= 0 && box.x + box.width <= width + 1 && (width >= 640 || box.width >= width - 2) }).toBe(true)
|
||||
expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true)
|
||||
await page.screenshot({ path: "docs/ux-ui/screens/certificates-issue-" + name + ".png", fullPage: true })
|
||||
await page.keyboard.press("Escape"); await expect(dialog).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Выпустить сертификат", exact: true })).toBeFocused()
|
||||
})
|
||||