feat(certificates): добавить отключение автообновления на странице сертификатов
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -11,6 +11,7 @@ import { OpsPanel } from "@/components/ops-panel"
|
|||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
||||||
|
import { CertificateRenewSettingsPanel } from "@/components/certificates/certificate-renew-settings"
|
||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
@@ -243,7 +244,7 @@ function CertPartReference() {
|
|||||||
return (
|
return (
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
title="RouterOS 7 · /certificate — справка CLI"
|
title="RouterOS 7 · /certificate — справка CLI"
|
||||||
description="RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве."
|
description="RouterOS 7 умеет обновлять Let's Encrypt сам. Этот CLI — справка; автообновление MM включается панелью выше."
|
||||||
contentClassName="px-5 py-4"
|
contentClassName="px-5 py-4"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||||
@@ -715,6 +716,8 @@ export default function CertificatesPage() {
|
|||||||
|
|
||||||
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
|
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
|
||||||
|
|
||||||
|
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} />
|
||||||
|
|
||||||
{liveReady && (
|
{liveReady && (
|
||||||
<CertPartAcmeSettings
|
<CertPartAcmeSettings
|
||||||
acmeDirectoryUrl={acmeDirectoryUrl}
|
acmeDirectoryUrl={acmeDirectoryUrl}
|
||||||
|
|||||||
@@ -1127,7 +1127,11 @@ export default function DataCollectionPage() {
|
|||||||
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
|
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
|
disabled={!draftCertRenewEnabled}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
Вкл/выкл автообновления MM — также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
|||||||
@@ -113,6 +113,15 @@ export async function collectCertificatesRenewOnce(): Promise<CertificatesRenewR
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stillOn = await getCertificateRenewSettings()
|
||||||
|
if (!stillOn.enabled) {
|
||||||
|
item.action = "skipped"
|
||||||
|
item.message = "Автообновление выключено"
|
||||||
|
snapshot.skippedTargets += 1
|
||||||
|
snapshot.targets?.push(item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const jobId = randomUUID()
|
const jobId = randomUUID()
|
||||||
await createIssueJobRecord({
|
await createIssueJobRecord({
|
||||||
id: jobId,
|
id: jobId,
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
|
import { FormField, FormToggle } from "@/components/form-kit"
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
getCertificateRenewSettings,
|
||||||
|
putCertificateRenewSettings,
|
||||||
|
} from "@/shared/api/certificates"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Автообновление сертификатов через MM (ACME DNS-01).
|
||||||
|
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3
|
||||||
|
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/alert · https://reui.io/docs/components/base/badge
|
||||||
|
*/
|
||||||
|
export function CertificateRenewSettingsPanel({
|
||||||
|
backendUrl,
|
||||||
|
liveReady,
|
||||||
|
}: {
|
||||||
|
backendUrl: string
|
||||||
|
liveReady: boolean
|
||||||
|
}) {
|
||||||
|
const [enabled, setEnabled] = useState(true)
|
||||||
|
const [intervalDraft, setIntervalDraft] = useState("21600")
|
||||||
|
const [daysDraft, setDaysDraft] = useState("30")
|
||||||
|
const [lastCollectedAt, setLastCollectedAt] = useState<string | null>(null)
|
||||||
|
const [lastError, setLastError] = useState<string | null>(null)
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const [toggleBusy, setToggleBusy] = useState(false)
|
||||||
|
const [saveBusy, setSaveBusy] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!liveReady) return
|
||||||
|
try {
|
||||||
|
const s = await getCertificateRenewSettings(backendUrl)
|
||||||
|
setEnabled(s.enabled)
|
||||||
|
setIntervalDraft(String(s.intervalSec))
|
||||||
|
setDaysDraft(String(s.renewBeforeDays))
|
||||||
|
setLastCollectedAt(s.lastCollectedAt ?? null)
|
||||||
|
setLastError(s.lastError ?? null)
|
||||||
|
setLoaded(true)
|
||||||
|
} catch (e) {
|
||||||
|
setLoaded(true)
|
||||||
|
toast.error(e instanceof Error ? e.message : "Не удалось загрузить настройки автообновления")
|
||||||
|
}
|
||||||
|
}, [backendUrl, liveReady])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void load()
|
||||||
|
})
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
async function handleEnabledChange(next: boolean) {
|
||||||
|
if (!liveReady || toggleBusy) return
|
||||||
|
const prev = enabled
|
||||||
|
setEnabled(next)
|
||||||
|
setToggleBusy(true)
|
||||||
|
try {
|
||||||
|
const saved = await putCertificateRenewSettings(backendUrl, { enabled: next })
|
||||||
|
setEnabled(saved.enabled)
|
||||||
|
toast.success(next ? "Автообновление через MikrotikManager включено" : "Автообновление через MikrotikManager выключено")
|
||||||
|
} catch (e) {
|
||||||
|
setEnabled(prev)
|
||||||
|
toast.error(e instanceof Error ? e.message : "Не удалось сохранить")
|
||||||
|
} finally {
|
||||||
|
setToggleBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveSchedule() {
|
||||||
|
if (!liveReady || saveBusy) return
|
||||||
|
const intervalSec = Math.max(300, Number.parseInt(intervalDraft, 10) || 21600)
|
||||||
|
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(daysDraft, 10) || 30))
|
||||||
|
setSaveBusy(true)
|
||||||
|
try {
|
||||||
|
const saved = await putCertificateRenewSettings(backendUrl, { intervalSec, renewBeforeDays })
|
||||||
|
setIntervalDraft(String(saved.intervalSec))
|
||||||
|
setDaysDraft(String(saved.renewBeforeDays))
|
||||||
|
toast.success("Расписание автообновления сохранено")
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Не удалось сохранить расписание")
|
||||||
|
} finally {
|
||||||
|
setSaveBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const interactionsOff = !liveReady || toggleBusy || (liveReady && !loaded)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OpsPanel
|
||||||
|
title="Автообновление через MikrotikManager"
|
||||||
|
description="Фоновый выпуск Let's Encrypt (Cloudflare DNS-01) для сертификатов, выпущенных из этой панели. Ручной выпуск не зависит от переключателя."
|
||||||
|
headerRight={
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant={!liveReady ? "warning-light" : enabled ? "success-light" : "secondary"}
|
||||||
|
>
|
||||||
|
{!liveReady ? "нет backend" : enabled ? "Включено" : "Выключено"}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
contentClassName="px-5 py-4 flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Обновлять сертификаты из MM</p>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
Если ACME уже крутит RouterOS — выключите, чтобы не было двойного перевыпуска.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FormToggle checked={enabled} onChange={handleEnabledChange} disabled={interactionsOff} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!liveReady ? (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertTitle>Нет подключения к API</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Переключатель станет активен, когда backend доступен. Планировщик читает тот же флаг, что и страница «Сбор данных».
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : enabled ? (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertTitle>Не смешивайте с ACME RouterOS</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
MM обновляет только сертификаты, выпущенные через эту страницу. Встроенный Let's Encrypt на
|
||||||
|
устройстве для тех же имён лучше не включать одновременно.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert variant="info">
|
||||||
|
<AlertTitle>Обновление отдано RouterOS</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Планировщик MM больше не проверяет срок и не перевыпускает сертификаты. Ручной выпуск и импорт
|
||||||
|
остаются доступны.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={cn("flex flex-col gap-4", !enabled && "pointer-events-none opacity-40")}>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<FormField label="Интервал проверки" hint="Секунды, минимум 300">
|
||||||
|
<Input
|
||||||
|
inputMode="numeric"
|
||||||
|
value={intervalDraft}
|
||||||
|
onChange={(e) => setIntervalDraft(e.target.value)}
|
||||||
|
disabled={!liveReady || saveBusy}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Обновлять за" hint="Дней до истечения, 1–90">
|
||||||
|
<Input
|
||||||
|
inputMode="numeric"
|
||||||
|
value={daysDraft}
|
||||||
|
onChange={(e) => setDaysDraft(e.target.value)}
|
||||||
|
disabled={!liveReady || saveBusy}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={!liveReady || saveBusy || !enabled} onClick={() => void handleSaveSchedule()}>
|
||||||
|
Сохранить расписание
|
||||||
|
</Button>
|
||||||
|
<Link href="/data-collection" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 text-xs")}>
|
||||||
|
Журнал планировщика →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lastCollectedAt || lastError ? (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Последний прогон:{" "}
|
||||||
|
{lastCollectedAt ? new Date(lastCollectedAt).toLocaleString("ru-RU") : "ещё не было"}
|
||||||
|
{lastError ? ` · ошибка: ${lastError}` : ""}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</OpsPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ export const SCHEDULER_JOB_DESCRIPTIONS: Record<string, string> = {
|
|||||||
gre_bgp:
|
gre_bgp:
|
||||||
"Опрос GRE-туннелей и BGP-сессий на включённых серверах, запись сэмплов в PostgreSQL для движка оповещений.",
|
"Опрос GRE-туннелей и BGP-сессий на включённых серверах, запись сэмплов в PostgreSQL для движка оповещений.",
|
||||||
certificates_renew:
|
certificates_renew:
|
||||||
"Проверка сертификатов, выпущенных через UI, и автообновление через ACME DNS-01 (Cloudflare) до истечения срока.",
|
"Автообновление сертификатов, выпущенных через UI (ACME DNS-01 / Cloudflare). Отключается на странице «Сертификаты», если ACME ведёт RouterOS.",
|
||||||
backups:
|
backups:
|
||||||
"Плановые бэкапы RouterOS по расписанию со страницы «Бэкапы»; тик планировщика раз в минуту.",
|
"Плановые бэкапы RouterOS по расписанию со страницы «Бэкапы»; тик планировщика раз в минуту.",
|
||||||
alert_engine:
|
alert_engine:
|
||||||
|
|||||||
Reference in New Issue
Block a user