fix(ui): сохранять достоверные снимки сертификатов
This commit is contained in:
1 parent
17f03e7af1
commit
b184922f61
7 files changed
+242
-93
No files matched your search
@@ -5,7 +5,7 @@ import { PageHeader } from "@/components/page-header"
|
||||
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { routerCertificates, servers as mockServers } from "@/lib/data"
|
||||
import type { CertStatus, Server } from "@/lib/data"
|
||||
import type { Server } from "@/lib/data"
|
||||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
@@ -28,15 +28,15 @@ import {
|
||||
} from "@/components/ui/sheet"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { listServers } from "@/shared/api/servers"
|
||||
import { can } from "@/lib/auth"
|
||||
import { useCertificateCatalog } from "@/lib/use-certificate-catalog"
|
||||
import type { CertificateView } from "@/lib/certificate-view"
|
||||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||
import {
|
||||
createCertificateIssueJob,
|
||||
getAcmeSettings,
|
||||
getCertificateIssueJob,
|
||||
listCertificates,
|
||||
putAcmeSettings,
|
||||
refreshCertificates,
|
||||
testAcmeSettings,
|
||||
} from "@/shared/api/certificates"
|
||||
import { toast } from "sonner"
|
||||
@@ -79,7 +79,7 @@ function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
|
||||
}
|
||||
}
|
||||
|
||||
function CertPartAlertExpired({ expired }: { expired: 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" />
|
||||
@@ -95,7 +95,7 @@ function CertPartAlertExpired({ expired }: { expired: CertificateDto[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function CertPartAlertExpiring({ expiring }: { expiring: CertificateDto[] }) {
|
||||
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" />
|
||||
@@ -115,10 +115,12 @@ function CertPartKpi({
|
||||
displayCerts,
|
||||
expiring,
|
||||
expired,
|
||||
complete,
|
||||
}: {
|
||||
displayCerts: CertificateDto[]
|
||||
expiring: CertificateDto[]
|
||||
expired: CertificateDto[]
|
||||
displayCerts: CertificateView[]
|
||||
expiring: CertificateView[]
|
||||
expired: CertificateView[]
|
||||
complete: boolean
|
||||
}) {
|
||||
return (
|
||||
<KpiStatGrid
|
||||
@@ -127,21 +129,21 @@ function CertPartKpi({
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего",
|
||||
value: displayCerts.length,
|
||||
value: complete ? displayCerts.length : "—",
|
||||
icon: <ShieldCheckIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "valid",
|
||||
label: "Действующих",
|
||||
value: displayCerts.filter((c) => c.status === "valid").length,
|
||||
value: complete ? displayCerts.filter((c) => c.status === "valid").length : "—",
|
||||
icon: <BadgeCheckIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "expiring",
|
||||
label: "Истекают",
|
||||
value: expiring.length,
|
||||
value: complete ? expiring.length : "—",
|
||||
icon: <AlertTriangleIcon className="size-4" />,
|
||||
iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground",
|
||||
variant: expiring.length > 0 ? "warning" : "default",
|
||||
@@ -149,7 +151,7 @@ function CertPartKpi({
|
||||
{
|
||||
id: "expired",
|
||||
label: "Истёкших",
|
||||
value: expired.length,
|
||||
value: complete ? expired.length : "—",
|
||||
icon: <AlertCircleIcon className="size-4" />,
|
||||
iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground",
|
||||
variant: expired.length > 0 ? "destructive" : "default",
|
||||
@@ -416,16 +418,24 @@ function CertPartIssueForm({
|
||||
}
|
||||
|
||||
export default function CertificatesPage() {
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
if (!prefsHydrated) return <p role="status" className="p-5">Загрузка настроек источника…</p>
|
||||
return <CertificatesContent key={mode + ":" + backendUrl} />
|
||||
}
|
||||
|
||||
function CertificatesContent() {
|
||||
const { mode, backendUrl, prefsHydrated, backendStatus } = useDataSource()
|
||||
const isLive = prefsHydrated && mode === "live"
|
||||
const liveReady = isLive && backendStatus === true
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<CertStatus | "all">("all")
|
||||
const [certificates, setCertificates] = useState<CertificateDto[]>([])
|
||||
const [loadState, setLoadState] = useState<"idle" | "loading" | "error">("idle")
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [serverList, setServerList] = useState<Server[]>([])
|
||||
const canRead = can("mm:certificates:read")
|
||||
const canWrite = can("mm:certificates:write")
|
||||
const canReadServers = can("mm:servers:read")
|
||||
const catalog = useCertificateCatalog(backendUrl, isLive, canRead, canReadServers)
|
||||
const { snapshots, loading, error: loadError, serverError } = catalog
|
||||
const serverList = useMemo(() => catalog.servers.map(toFrontendServer), [catalog.servers])
|
||||
const [statusFilter, setStatusFilter] = useState<CertificateView["status"] | "all">("all")
|
||||
|
||||
const [issueOpen, setIssueOpen] = useState(false)
|
||||
const [issueStep, setIssueStep] = useState(1)
|
||||
@@ -450,9 +460,9 @@ export default function CertificatesPage() {
|
||||
|
||||
const displayCerts = useMemo(() => {
|
||||
if (!prefsHydrated) return []
|
||||
if (isLive) return certificates
|
||||
if (isLive) return Object.values(snapshots).flatMap(snapshot => snapshot.confirmed?.certificates ?? [])
|
||||
return routerCertificates.map(mockToDto)
|
||||
}, [prefsHydrated, isLive, certificates])
|
||||
}, [prefsHydrated, isLive, snapshots])
|
||||
|
||||
const displayServers = isLive ? serverList : mockServers
|
||||
|
||||
@@ -468,12 +478,12 @@ export default function CertificatesPage() {
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
status: isLive ? catalog.servers.find(server => String(server.id) === s.id)?.status ?? undefined : s.status,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
meta: String(displayCerts.filter((c) => c.serverId === s.id).length),
|
||||
meta: isLive && !snapshots[s.id]?.confirmed ? "—" : String(displayCerts.filter((c) => c.serverId === s.id).length),
|
||||
}))
|
||||
), [displayServers, displayCerts])
|
||||
), [displayServers, displayCerts, isLive, catalog.servers, snapshots])
|
||||
|
||||
const serverById = useMemo(() => {
|
||||
const map = new Map<string, Server>()
|
||||
@@ -481,37 +491,10 @@ export default function CertificatesPage() {
|
||||
return map
|
||||
}, [isLive, serverList])
|
||||
|
||||
const loadLive = useCallback(
|
||||
async (silent = false) => {
|
||||
if (!isLive) return
|
||||
if (!silent) setLoadState("loading")
|
||||
setLoadError(null)
|
||||
try {
|
||||
const [certRes, serversRes] = await Promise.all([
|
||||
listCertificates(backendUrl),
|
||||
listServers(backendUrl),
|
||||
])
|
||||
setCertificates(certRes.certificates)
|
||||
setServerList(serversRes.map(toFrontendServer))
|
||||
if (certRes.failures.length > 0 && certRes.certificates.length === 0) {
|
||||
setLoadError(
|
||||
certRes.failures.map((f) => `${f.serverName ?? f.serverId}: ${f.error}`).join("; "),
|
||||
)
|
||||
} else if (certRes.failures.length > 0) {
|
||||
toast.warning(`Часть серверов недоступна: ${certRes.failures.length}`)
|
||||
}
|
||||
setLoadState("idle")
|
||||
} catch (e) {
|
||||
setCertificates([])
|
||||
setLoadError(e instanceof Error ? e.message : "Не удалось загрузить сертификаты")
|
||||
setLoadState("error")
|
||||
}
|
||||
},
|
||||
[backendUrl, isLive],
|
||||
)
|
||||
const loadLive = catalog.reload
|
||||
|
||||
const loadAcmeSettings = useCallback(async () => {
|
||||
if (!liveReady) return
|
||||
if (!liveReady || !canRead) return
|
||||
try {
|
||||
const s = await getAcmeSettings(backendUrl)
|
||||
setAcmeDirectoryUrl(s.directoryUrl)
|
||||
@@ -520,30 +503,22 @@ export default function CertificatesPage() {
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [backendUrl, liveReady])
|
||||
}, [backendUrl, liveReady, canRead])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
queueMicrotask(() => {
|
||||
setCertificates([])
|
||||
setServerList([])
|
||||
setLoadState("idle")
|
||||
setLoadError(null)
|
||||
})
|
||||
return
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
void loadLive()
|
||||
void loadAcmeSettings()
|
||||
})
|
||||
}, [isLive, loadLive, loadAcmeSettings])
|
||||
queueMicrotask(() => { void loadAcmeSettings() })
|
||||
}, [loadAcmeSettings])
|
||||
|
||||
const expiring = useMemo(
|
||||
() => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
|
||||
() => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft !== null && c.daysLeft >= 0 && c.daysLeft <= 30),
|
||||
[scopedCerts],
|
||||
)
|
||||
const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts])
|
||||
|
||||
const scopeSnapshots = Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId)
|
||||
const scopeKnown = !isLive || canRead && catalog.hasState && !loadError && (selectedServerId === ALL_SERVERS_ID || !!snapshots[selectedServerId]) && scopeSnapshots.every(snapshot => snapshot.latest.error === null)
|
||||
const classificationsKnown = scopeKnown && scopedCerts.every(cert => cert.status !== "unknown" && cert.daysLeft !== null)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return scopedCerts.filter((c) => {
|
||||
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
||||
@@ -559,14 +534,7 @@ export default function CertificatesPage() {
|
||||
}, [scopedCerts, search, statusFilter])
|
||||
|
||||
async function handleRefresh() {
|
||||
if (!liveReady) return
|
||||
try {
|
||||
const res = await refreshCertificates(backendUrl)
|
||||
setCertificates(res.certificates)
|
||||
toast.success("Список сертификатов обновлён")
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Ошибка обновления")
|
||||
}
|
||||
await loadLive()
|
||||
}
|
||||
|
||||
async function pollIssueJob(jobId: string) {
|
||||
@@ -576,7 +544,7 @@ export default function CertificatesPage() {
|
||||
if (job.status === "done") {
|
||||
toast.success("Сертификат выпущен и импортирован на роутер")
|
||||
setIssueOpen(false)
|
||||
await loadLive(true)
|
||||
await loadLive()
|
||||
return
|
||||
}
|
||||
if (job.status === "failed") {
|
||||
@@ -656,7 +624,8 @@ export default function CertificatesPage() {
|
||||
onSelect={setSelectedServerId}
|
||||
showAll
|
||||
allCount={displayServers.length}
|
||||
loading={isLive && loadState === "loading" && displayServers.length === 0}
|
||||
preserveSelection
|
||||
loading={isLive && loading && displayServers.length === 0}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
|
||||
@@ -666,21 +635,21 @@ export default function CertificatesPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!liveReady || loadState === "loading"}
|
||||
disabled={!isLive || loading || (!canRead && !canReadServers)}
|
||||
onClick={() => {
|
||||
void handleRefresh()
|
||||
}}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||
<Button variant="outline" size="sm" disabled title="Импорт пока не подключён к API" onClick={() => setImportOpen(true)}>
|
||||
<UploadIcon className="size-4" />
|
||||
Импорт
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!liveReady || issueBusy}
|
||||
disabled={!liveReady || !canWrite || !catalog.hasServers || !!serverError || issueBusy}
|
||||
onClick={() => {
|
||||
setIssueStep(1)
|
||||
if (selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
|
||||
@@ -702,11 +671,20 @@ export default function CertificatesPage() {
|
||||
</div>
|
||||
)}
|
||||
{loadError && isLive && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{loadError}
|
||||
<div role="alert" aria-label="Каталог сертификатов" className="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 && !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>}
|
||||
<p className="text-xs text-muted-foreground">{snapshot.latest.serverName}: {snapshot.confirmed ? "Подтверждённые данные от " + new Date(snapshot.confirmed.collectedAt).toLocaleString("ru-RU") : "Успешного чтения нет"}</p>
|
||||
</div>)}
|
||||
|
||||
{(expiring.length > 0 || expired.length > 0) && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{expired.length > 0 && <CertPartAlertExpired expired={expired} />}
|
||||
@@ -714,11 +692,12 @@ export default function CertificatesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
|
||||
{!classificationsKnown && <p role="status" className="text-xs text-muted-foreground">Сводка неполная: часть устройств, статусов или сроков неизвестна. Таблица показывает доступные снимки.</p>}
|
||||
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} complete={classificationsKnown} />
|
||||
|
||||
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} />
|
||||
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady && canWrite} />
|
||||
|
||||
{liveReady && (
|
||||
{liveReady && canWrite && (
|
||||
<CertPartAcmeSettings
|
||||
acmeDirectoryUrl={acmeDirectoryUrl}
|
||||
setAcmeDirectoryUrl={setAcmeDirectoryUrl}
|
||||
@@ -750,15 +729,16 @@ export default function CertificatesPage() {
|
||||
{ value: "valid", label: "Действующие" },
|
||||
{ value: "expired", label: "Истёкшие" },
|
||||
{ value: "revoked", label: "Отозванные" },
|
||||
{ value: "unknown", label: "Неизвестные" },
|
||||
],
|
||||
}}
|
||||
countLabel={`${filtered.length} сертификатов`}
|
||||
countLabel={isLive && !catalog.hasState ? "Количество неизвестно" : `${filtered.length} строк в снимках`}
|
||||
/>
|
||||
<CertificatesDataGrid
|
||||
{isLive && scopedCerts.length === 0 && !scopeKnown ? <p role="status" className="p-5">{loading ? "Загрузка сертификатов…" : "Список сертификатов пока неизвестен"}</p> : <CertificatesDataGrid
|
||||
certificates={filtered}
|
||||
serverMap={serverById}
|
||||
isLoading={prefsHydrated && isLive && loadState === "loading" && displayCerts.length === 0}
|
||||
/>
|
||||
isLoading={isLive && loading && displayCerts.length === 0}
|
||||
/>}
|
||||
</DataPageCard>
|
||||
|
||||
<CertPartReference />
|
||||
|
||||
+1
-1
@@ -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А.17 — подключение observed сертификатов в страницу/таблицу/детали. API и snapshot merge подготовлены в 11А.14–11А.16; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты.
|
||||
Актуальная точка продолжения: 11А.17.3 — URL-контекст и поздние ответы сертификатов. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.2; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты.
|
||||
+10
-1
@@ -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А.16; пользователи/контейнеры приняты локально, сертификаты: API/reader готовы; далее 11А.17 |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.17.2; пользователи/контейнеры приняты локально, сертификаты: state каталог подключён; далее 11А.17.3 |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
@@ -36,6 +36,15 @@
|
||||
|
||||
|
||||
|
||||
## Блок 11А.17.2 — state каталог на странице сертификатов (2026-10-01)
|
||||
|
||||
- Страница читает readCertificatesState и независимо строгий каталог серверов. Снимки сохраняются при общем/частичном отказе; подтверждённое empty очищает прежние строки. Отображаются время каталога и время последнего успешного чтения каждого устройства.
|
||||
- useCertificateCatalog отменяет чтения при unmount/смене источника, использует revision и общий срок 30s. Результат одного источника не ждёт другой; источники разделены key mode/backend после prefsHydrated.
|
||||
- Неизвестная/частичная сводка показывает — с объяснением, фильтр неизвестных статусов доступен. Выбор сервера сохраняется при отказе каталога; неизвестный статус сервера не превращается в offline на rail.
|
||||
- Refresh теперь повторяет строгий GET state, не подмешивает legacy DTO и не объявляет частичный результат полным успехом. Write-действия требуют права сертификатов, выпуск также требует доступного каталога серверов. Неподключённый импорт отключён вместо ложного сообщения об успехе.
|
||||
- 12 профильных HTTP E2E, TypeScript, ESLint и production frontend прошли. API контракт/сетевые правила не менялись.
|
||||
- Следующий блок 11А.17.3: URL server/status/q и проверки позднего ответа при смене mode/backend. Затем отдельные блоки ACME/renew, issue polling, UI/экспорт и приёмка. Сертификаты в целом, резервные копии, 11Б/12 не приняты; реальные RouterOS/ACME/Portal проверки остаются внешними.
|
||||
|
||||
## Блок 11А.17.1 — nullable представление сертификатов (2026-10-01)
|
||||
|
||||
- CertificatesDataGrid и раскрытые детали принимают CertificateView, совместимый с legacy/mock DTO и новым CertificateObserved. Добавлен неизвестный статус; null срок, размер ключа, дата и доверие отображаются явно, без 0/false. Известные false и 0 дней сохранены.
|
||||
|
||||
@@ -204,3 +204,12 @@
|
||||
[ACME/экспорт: последующие блоки]
|
||||
|
||||
Проверенные заготовки: shared/api/certificates.ts (readCertificatesState), lib/certificate-snapshots.ts, новый contracts CertificateObserved. Legacy API/renew/issue ещё используются, их миграцию не считать завершённой.
|
||||
|
||||
## Блок 11А.17.2 — state каталог на странице сертификатов (2026-10-01)
|
||||
|
||||
- Страница читает readCertificatesState и независимо строгий каталог серверов. Снимки сохраняются при общем/частичном отказе; подтверждённое empty очищает прежние строки. Отображаются время каталога и время последнего успешного чтения каждого устройства.
|
||||
- useCertificateCatalog отменяет чтения при unmount/смене источника, использует revision и общий срок 30s. Результат одного источника не ждёт другой; источники разделены key mode/backend после prefsHydrated.
|
||||
- Неизвестная/частичная сводка показывает — с объяснением, фильтр неизвестных статусов доступен. Выбор сервера сохраняется при отказе каталога; неизвестный статус сервера не превращается в offline на rail.
|
||||
- Refresh теперь повторяет строгий GET state, не подмешивает legacy DTO и не объявляет частичный результат полным успехом. Write-действия требуют права сертификатов, выпуск также требует доступного каталога серверов. Неподключённый импорт отключён вместо ложного сообщения об успехе.
|
||||
- 12 профильных HTTP E2E, TypeScript, ESLint и production frontend прошли. API контракт/сетевые правила не менялись.
|
||||
- Следующий блок 11А.17.3: URL server/status/q и проверки позднего ответа при смене mode/backend. Затем отдельные блоки ACME/renew, issue polling, UI/экспорт и приёмка. Сертификаты в целом, резервные копии, 11Б/12 не приняты; реальные RouterOS/ACME/Portal проверки остаются внешними.
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { serverListSchema, type ServerRead } from "@mmapp/contracts/servers"
|
||||
import { readCertificatesState } from "@/shared/api/certificates"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { mergeCertificateSnapshots, type CertificateSnapshots } from "@/lib/certificate-snapshots"
|
||||
|
||||
export function useCertificateCatalog(backendUrl: string, live: boolean, canRead: boolean, canReadServers: boolean) {
|
||||
const [snapshots, setSnapshots] = useState<CertificateSnapshots>({})
|
||||
const [servers, setServers] = useState<ServerRead[]>([])
|
||||
const [hasState, setHasState] = useState(false)
|
||||
const [hasServers, setHasServers] = useState(false)
|
||||
const [loading, setLoading] = useState(live)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [serverError, setServerError] = useState<string | null>(null)
|
||||
const [collectedAt, setCollectedAt] = useState<string | null>(null)
|
||||
const controller = useRef<AbortController | null>(null)
|
||||
const revision = useRef(0)
|
||||
const alive = useRef(true)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!live || !alive.current) return
|
||||
controller.current?.abort()
|
||||
const current = ++revision.current
|
||||
const abort = new AbortController()
|
||||
controller.current = abort
|
||||
const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(30_000)])
|
||||
const active = () => alive.current && !abort.signal.aborted && revision.current === current
|
||||
setLoading(true)
|
||||
await Promise.allSettled([
|
||||
canRead ? readCertificatesState(backendUrl, { signal }).then(result => {
|
||||
if (!active()) return
|
||||
setSnapshots(previous => mergeCertificateSnapshots(previous, result))
|
||||
setHasState(true)
|
||||
setCollectedAt(result.collectedAt)
|
||||
setError(null)
|
||||
}).catch(reason => {
|
||||
if (active()) setError(reason instanceof Error ? reason.message : "Ошибка каталога сертификатов")
|
||||
}) : Promise.resolve(),
|
||||
canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(payload => {
|
||||
const result = serverListSchema.parse(payload)
|
||||
if (!active()) return
|
||||
setServers(result)
|
||||
setHasServers(true)
|
||||
setServerError(null)
|
||||
}).catch(reason => {
|
||||
if (active()) setServerError(reason instanceof Error ? reason.message : "Ошибка каталога серверов")
|
||||
}) : Promise.resolve(),
|
||||
])
|
||||
if (active()) setLoading(false)
|
||||
}, [backendUrl, live, canRead, canReadServers])
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true
|
||||
queueMicrotask(() => { if (alive.current) void reload() })
|
||||
return () => { alive.current = false; controller.current?.abort() }
|
||||
}, [reload])
|
||||
|
||||
return { snapshots, servers, hasState, hasServers, loading, error, serverError, collectedAt, reload }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { test, expect, type Page } from "@playwright/test"
|
||||
import { certificate, certificateState, server } from "./fixtures/certificates"
|
||||
|
||||
const refresh = (page: Page) => page.getByRole("button", { name: "Обновить", exact: true })
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", r => {
|
||||
const path = new URL(r.request().url()).pathname
|
||||
return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [server] : path === "/api/certificates/state" ? certificateState() : { directoryUrl: "https://acme.example.test/directory", tokenConfigured: false, enabled: false, intervalSec: 300, daysBeforeExpiry: 30 } })
|
||||
})
|
||||
})
|
||||
for (const kind of ["API", "malformed", "duplicate", "foreign"] as const) test("Сертификаты: первоначальный отказ " + kind + " и восстановление", async ({ page }) => {
|
||||
let fail = true
|
||||
await page.route("**/api/certificates/state", r => r.fulfill(fail ? kind === "API" ? { status: 503, json: { error: "Отказ чтения" } } : { json: kind === "malformed" ? {} : certificateState(kind === "duplicate" ? [certificate, certificate] : [{ ...certificate, serverId: "2" }]) } : { json: certificateState() }))
|
||||
await page.goto("/certificates")
|
||||
await expect(page.getByRole("alert", { name: "Каталог сертификатов", exact: true })).toContainText("Сертификаты пока неизвестны")
|
||||
await expect(page.getByText("Нет сертификатов", { exact: true })).toHaveCount(0)
|
||||
fail = false; await refresh(page).click()
|
||||
await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
|
||||
})
|
||||
test("Сертификаты: nullable поля и неизвестная классификация", async ({ page }) => {
|
||||
await page.goto("/certificates")
|
||||
await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Срок неизвестен", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Неизвестен", { exact: true })).toBeVisible()
|
||||
await page.getByText(certificate.name, { exact: true }).click()
|
||||
await expect(page.getByText("Неизвестно", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("0 бит", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("status").filter({ hasText: "Сводка неполная" })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Импорт", exact: true })).toBeDisabled()
|
||||
})
|
||||
test("Сертификаты: ошибка сервера не скрывает строки", async ({ page }) => {
|
||||
await page.route("**/api/servers", r => r.fulfill({ status: 503, json: { error: "Серверы отказали" } }))
|
||||
await page.goto("/certificates")
|
||||
await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("alert", { name: "Серверы сертификатов" })).toContainText("Каталог серверов пока неизвестен")
|
||||
await expect(page.getByRole("button", { name: "Выпустить сертификат", exact: true })).toBeDisabled()
|
||||
})
|
||||
test("Сертификаты: общий фоновый отказ сохраняет данные", async ({ page }) => {
|
||||
let fail = false
|
||||
await page.route("**/api/certificates/state", r => r.fulfill(fail ? { status: 503, json: { error: "Фоновый отказ" } } : { json: certificateState() }))
|
||||
await page.goto("/certificates"); await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
|
||||
fail = true; await refresh(page).click()
|
||||
await expect(page.getByRole("alert", { name: "Каталог сертификатов", exact: true })).toContainText("Последние данные сохранены")
|
||||
await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
|
||||
})
|
||||
test("Сертификаты: частичный отказ и подтверждённый пустой снимок", async ({ page }) => {
|
||||
let phase = 0
|
||||
await page.route("**/api/certificates/state", r => r.fulfill({ json: phase === 0 ? certificateState() : certificateState([], { error: phase === 1 ? "Устройство недоступно" : null }) }))
|
||||
await page.goto("/certificates"); await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
|
||||
phase = 1; await refresh(page).click()
|
||||
await expect(page.getByRole("alert", { name: "Сертификаты сервера 1", exact: true })).toContainText("Последние сертификаты сохранены")
|
||||
await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
|
||||
phase = 2; await refresh(page).click()
|
||||
await expect(page.getByText("Нет сертификатов", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText(certificate.name, { exact: true })).toHaveCount(0)
|
||||
})
|
||||
test("Сертификаты: медленные серверы не задерживают строки", async ({ page }) => {
|
||||
let release!: () => void; const gate = new Promise<void>(r => release = r)
|
||||
await page.route("**/api/servers", async r => { await gate; await r.fulfill({ json: [server] }).catch(() => {}) })
|
||||
await page.goto("/certificates"); await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
|
||||
release(); await expect(refresh(page)).toBeEnabled()
|
||||
})
|
||||
test("Сертификаты: до ответа нет ложного пустого каталога", async ({ page }) => {
|
||||
let release!: () => void; const gate = new Promise<void>(r => release = r)
|
||||
await page.route("**/api/certificates/state", async r => { await gate; await r.fulfill({ json: certificateState() }).catch(() => {}) })
|
||||
await page.goto("/certificates"); await expect(page.getByRole("status").filter({ hasText: "Загрузка сертификатов…" })).toBeVisible()
|
||||
await expect(page.getByText("Нет сертификатов", { exact: true })).toHaveCount(0)
|
||||
release(); await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
for (const serverRead of [false, true]) test("Сертификаты: read-only права и server read " + serverRead, async ({ page }) => {
|
||||
await page.addInitScript(({ serverRead }) => localStorage.setItem("mmapp_token", "header." + btoa(JSON.stringify({ sub: "test", apps: ["mm"], permissions: ["mm:certificates:read", ...(serverRead ? ["mm:servers:read"] : [])], exp: Math.floor(Date.now() / 1000) + 3600 })) + ".signature"), { serverRead })
|
||||
await page.route("**/api/auth/config", r => r.fulfill({ json: { required: true } }))
|
||||
let serverRequests = 0
|
||||
await page.route("**/api/servers", r => { serverRequests++; return r.fulfill({ json: [server] }) })
|
||||
await page.goto("/certificates")
|
||||
await expect(page.getByText(certificate.name, { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Выпустить сертификат", exact: true })).toBeDisabled()
|
||||
await expect(page.getByRole("button", { name: "Сохранить настройки", exact: true })).toHaveCount(0)
|
||||
if (!serverRead) {
|
||||
await expect(page.getByRole("status").filter({ hasText: "mm:servers:read" })).toBeVisible()
|
||||
expect(serverRequests).toBe(0)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
export { server } from "./users"
|
||||
export const certificate = { id: "1:*A", rosId: "*A", name: "certificate-live", serverId: "1", commonName: "test.example", sans: [], issuedBy: "—", validFrom: null, validUntil: null, rawValidFrom: null, rawValidUntil: null, daysLeft: null, keySize: null, trusted: null, status: "unknown", usage: [] }
|
||||
export function certificateState(certificates: unknown[] = [certificate], overrides: Record<string, unknown> = {}) {
|
||||
return { collectedAt: "2026-10-01T00:00:00Z", servers: [{ serverId: "1", serverName: "Роутер", collectedAt: "2026-10-01T00:00:00Z", certificates, error: null, ...overrides }] }
|
||||
}
|
||||
Reference in new issue
Block a user