diff --git a/components/data-grids/certificate-days-cell.tsx b/components/data-grids/certificate-days-cell.tsx
new file mode 100644
index 00000000..f4ed6b63
--- /dev/null
+++ b/components/data-grids/certificate-days-cell.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import type { CertificateView } from "@/lib/certificate-view"
+import { cn } from "@/lib/utils"
+
+function daysLeftColor(days: number | null): string {
+ if (days === null) return "text-muted-foreground"
+ if (days <= 7) return "text-destructive"
+ if (days <= 30) return "text-warning"
+ return "text-success"
+}
+
+export function CertDaysCell({ cert }: { cert: CertificateView }) {
+ return (
+
+
+ {cert.daysLeft === null
+ ? "Срок неизвестен"
+ : cert.daysLeft < 0
+ ? "Истёк " + -cert.daysLeft + "д назад"
+ : cert.daysLeft + "д осталось"}
+
+
+ {cert.validUntil ?? "Дата окончания неизвестна"}
+
+
+ )
+}
diff --git a/components/data-grids/certificate-expanded-detail.tsx b/components/data-grids/certificate-expanded-detail.tsx
index 899e9257..2d59fc63 100644
--- a/components/data-grids/certificate-expanded-detail.tsx
+++ b/components/data-grids/certificate-expanded-detail.tsx
@@ -1,21 +1,20 @@
"use client"
-import type { CertificateDto } from "@mmapp/contracts/certificates"
+import type { CertificateView } from "@/lib/certificate-view"
-
-function CertificateExpandedDetail({ cert }: { cert: CertificateDto }) {
+function CertificateExpandedDetail({ cert }: { cert: CertificateView }) {
return (
-
Key size
-
{cert.keySize} bit
+
Размер ключа
+
{cert.keySize === null ? "Неизвестен" : cert.keySize + " бит"}
Действителен с
-
{cert.validFrom}
+
{cert.validFrom ?? "Дата неизвестна"}
-
SAN / Alt Names
+
Дополнительные имена (SAN)
{cert.sans.length > 0
? cert.sans.map((s) => (
@@ -27,9 +26,9 @@ function CertificateExpandedDetail({ cert }: { cert: CertificateDto }) {
-
Trusted
-
- {cert.trusted ? "Да (доверенный)" : "Нет (не доверенный)"}
+
Доверие
+
+ {cert.trusted === null ? "Неизвестно" : cert.trusted ? "Да (доверенный)" : "Нет (не доверенный)"}
diff --git a/components/data-grids/certificate-observed-view.test.tsx b/components/data-grids/certificate-observed-view.test.tsx
new file mode 100644
index 00000000..f5fe56d0
--- /dev/null
+++ b/components/data-grids/certificate-observed-view.test.tsx
@@ -0,0 +1,40 @@
+import assert from "node:assert/strict"
+import { test } from "node:test"
+import { renderToStaticMarkup } from "react-dom/server"
+import type { CertificateObserved } from "@mmapp/contracts/certificates"
+import { CertificateExpandedDetail } from "./certificate-expanded-detail"
+import { CertDaysCell } from "./certificate-days-cell"
+
+const cert: CertificateObserved = {
+ id: "1:*A", rosId: "*A", name: "unknown-cert", serverId: "1", commonName: "test.example",
+ sans: [], issuedBy: "—", validFrom: null, validUntil: null, rawValidFrom: null,
+ rawValidUntil: null, daysLeft: null, keySize: null, trusted: null, status: "unknown", usage: [],
+}
+
+test("unknown observed fields are explicit and do not become zero or untrusted", () => {
+ const details = renderToStaticMarkup()
+ const days = renderToStaticMarkup()
+ assert.match(details, /Неизвестен/)
+ assert.match(details, /Дата неизвестна/)
+ assert.match(details, /Неизвестно/)
+ assert.ok(!details.includes("0 бит"))
+ assert.ok(!details.includes("Нет (не доверенный)"))
+ assert.match(days, /Срок неизвестен/)
+ assert.match(days, /Дата окончания неизвестна/)
+ assert.doesNotMatch(days, /0д осталось/)
+})
+
+test("known false trust and zero remaining days are preserved", () => {
+ const known = { ...cert, trusted: false, keySize: 2048, daysLeft: 0, validUntil: "2026-10-01T00:00:00Z" }
+ const details = renderToStaticMarkup()
+ assert.ok(details.includes("Нет (не доверенный)"))
+ assert.match(details, /2048 бит/)
+ const days = renderToStaticMarkup()
+ assert.match(days, /0д осталось/)
+ assert.match(days, /2026-10-01T00:00:00Z/)
+})
+
+test("expired and positive observed days stay distinct", () => {
+ assert.match(renderToStaticMarkup(), /Истёк 3д назад/)
+ assert.match(renderToStaticMarkup(), /40д осталось/)
+})
diff --git a/components/data-grids/certificates-data-grid.tsx b/components/data-grids/certificates-data-grid.tsx
index 5246d443..c7e27aff 100644
--- a/components/data-grids/certificates-data-grid.tsx
+++ b/components/data-grids/certificates-data-grid.tsx
@@ -7,8 +7,9 @@ import {
type ColumnDef,
useTable,
} from "@tanstack/react-table"
-import type { CertStatus, Server } from "@/lib/data"
-import type { CertificateDto } from "@mmapp/contracts/certificates"
+import type { Server } from "@/lib/data"
+import type { CertificateView } from "@/lib/certificate-view"
+import { CertDaysCell } from "@/components/data-grids/certificate-days-cell"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
@@ -30,12 +31,19 @@ import {
ShieldAlertIcon,
ShieldCheckIcon,
ShieldOffIcon,
+ CircleHelpIcon,
} from "lucide-react"
const STATUS_CONFIG: Record<
- CertStatus,
+ CertificateView["status"],
{ label: string; icon: ReactNode; badge: string; row: string }
> = {
+ unknown: {
+ label: "Неизвестен",
+ icon: ,
+ badge: "bg-muted text-muted-foreground border-border",
+ row: "",
+ },
valid: {
label: "Действителен",
icon: ,
@@ -56,34 +64,14 @@ const STATUS_CONFIG: Record<
},
}
-function daysLeftColor(days: number): string {
- if (days < 0) return "text-red-500"
- if (days <= 7) return "text-red-500"
- if (days <= 30) return "text-amber-500"
- return "text-emerald-600 dark:text-emerald-400"
-}
-
-function CertDaysCell({ cert }: { cert: CertificateDto }) {
- return (
- <>
-
-
- {cert.daysLeft < 0 ? `Истёк ${-cert.daysLeft}д назад` : `${cert.daysLeft}д осталось`}
-
- {cert.validUntil}
-
- >
- )
-}
-
interface CertificatesDataGridProps {
- certificates: CertificateDto[]
+ certificates: CertificateView[]
serverMap: Map
isLoading?: boolean
}
function CertificatesDataGrid({ certificates, serverMap, isLoading }: CertificatesDataGridProps) {
- const columns = useMemo[]>(
+ const columns = useMemo[]>(
() => [
{
id: "name",
@@ -120,7 +108,7 @@ function CertificatesDataGrid({ certificates, serverMap, isLoading }: Certificat
headerTitle: "Сертификат",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
- expandedContent: (row: CertificateDto) => ,
+ expandedContent: (row: CertificateView) => ,
},
},
{
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md
index ac283b80..5a42f265 100644
--- a/docs/ux-ui-progress.md
+++ b/docs/ux-ui-progress.md
@@ -36,6 +36,12 @@
+## Блок 11А.17.1 — nullable представление сертификатов (2026-10-01)
+
+- CertificatesDataGrid и раскрытые детали принимают CertificateView, совместимый с legacy/mock DTO и новым CertificateObserved. Добавлен неизвестный статус; null срок, размер ключа, дата и доверие отображаются явно, без 0/false. Известные false и 0 дней сохранены.
+- Срок вынесен в CertDaysCell; неизвестные и известные значения используют семантические токены. Установленная композиция ReUI и API операций сохранены.
+- 3 проверки фактического HTML компонентов, TypeScript и профильный ESLint прошли. Страница пока использует legacy reader; следующий блок 11А.17.2 — подключение state каталога, независимое чтение серверов, частичные ошибки и последние данные.
+
## Блок 11А.16.1 — достоверное отображение срока сертификата (2026-10-01)
- Удалены декоративные шкалы из таблицы и раскрытых деталей сертификата: они делили оставшиеся дни на выдуманный общий срок 365 дней. Сертификаты могут иметь другую продолжительность, поэтому процент не был достоверным.
diff --git a/lib/certificate-view.ts b/lib/certificate-view.ts
new file mode 100644
index 00000000..a4ca1a56
--- /dev/null
+++ b/lib/certificate-view.ts
@@ -0,0 +1,4 @@
+import type { CertificateDto, CertificateObserved } from "@mmapp/contracts/certificates"
+
+/** Общий тип для демонстрационных строк и подтверждённых полей RouterOS. */
+export type CertificateView = CertificateDto | CertificateObserved