fix(ui): сохранять достоверные снимки сертификатов

This commit is contained in:
Denozordec committed 2026-10-01 13:40:32 +07:00
1 parent 17f03e7af1
commit b184922f61
7 files changed
+242 -93

No files matched your search

+71 -91
View File
@@ -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 />