"use client" import { useCallback, useEffect, useRef, 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 type { CertificateRenewSettingsDto } from "@mmapp/contracts/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, canRead = true, canWrite = true, }: { backendUrl: string liveReady: boolean canRead?: boolean canWrite?: boolean }) { const [confirmed, setConfirmed] = useState(null) const enabled = confirmed?.enabled ?? false const [intervalDraft, setIntervalDraft] = useState("") const [daysDraft, setDaysDraft] = useState("") const [loading, setLoading] = useState(liveReady && canRead) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const alive = useRef(true) const controller = useRef(null) const writeLock = useRef(false) const dirty = useRef(false) const load = useCallback(async () => { if (!liveReady || !canRead || writeLock.current || !alive.current) return controller.current?.abort() const abort = new AbortController() controller.current = abort setLoading(true) try { const saved = await getCertificateRenewSettings(backendUrl, { signal: abort.signal }) if (!alive.current || abort.signal.aborted) return setConfirmed(saved) if (!dirty.current) { setIntervalDraft(String(saved.intervalSec)) setDaysDraft(String(saved.renewBeforeDays)) } setError(null) } catch (reason) { if (alive.current && !abort.signal.aborted) setError(reason instanceof Error ? reason.message : "Не удалось прочитать настройки") } finally { if (alive.current && !abort.signal.aborted) setLoading(false) } }, [backendUrl, liveReady, canRead]) useEffect(() => { alive.current = true const cancel = () => { alive.current = false; controller.current?.abort() } return cancel }, []) useEffect(() => { queueMicrotask(() => { if (alive.current) void load() }) }, [load]) async function save(patch: { enabled?: boolean; intervalSec?: number; renewBeforeDays?: number }) { if (!liveReady || !canRead || !canWrite || !confirmed || loading || writeLock.current || !alive.current) return writeLock.current = true setBusy(true) const abort = new AbortController() controller.current = abort try { const saved = await putCertificateRenewSettings(backendUrl, patch, { signal: abort.signal }) if (!alive.current || abort.signal.aborted) return setConfirmed(saved) if (patch.intervalSec !== undefined) { setIntervalDraft(String(saved.intervalSec)) setDaysDraft(String(saved.renewBeforeDays)) dirty.current = false } setError(null) toast.success(patch.enabled !== undefined ? (saved.enabled ? "Автообновление включено" : "Автообновление выключено") : "Расписание автообновления сохранено") } catch (reason) { if (alive.current && !abort.signal.aborted) setError((reason instanceof Error ? reason.message : "Не удалось сохранить") + ". Подтверждённые настройки и черновик сохранены в форме; результат операции не подтверждён.") } finally { writeLock.current = false if (alive.current && !abort.signal.aborted) setBusy(false) } } function handleEnabledChange(next: boolean) { void save({ enabled: next }) } function handleSaveSchedule() { if (!/^[0-9]+$/.test(intervalDraft) || !/^[0-9]+$/.test(daysDraft) || !Number.isSafeInteger(Number(intervalDraft)) || Number(intervalDraft) < 300 || Number(daysDraft) < 1 || Number(daysDraft) > 90) { setError("Введите целые числа: интервал от 300 секунд, срок от 1 до 90 дней.") return } void save({ intervalSec: Number(intervalDraft), renewBeforeDays: Number(daysDraft) }) } const interactionsOff = !liveReady || !canRead || !canWrite || busy || loading || !confirmed const lastCollectedAt = confirmed?.lastCollectedAt const lastError = confirmed?.lastError return ( {!confirmed ? "Неизвестно" : enabled ? "Включено" : "Выключено"} } contentClassName="px-5 py-4 flex flex-col gap-4" > {error &&

{error}

} {loading &&

Загрузка настроек автообновления…

} {!canRead &&

Нет права mm:certificates:read.

} {!canWrite &&

Настройки доступны только для чтения.

} {liveReady && canRead && }

Обновлять сертификаты из MM

Если ACME уже крутит RouterOS — выключите, чтобы не было двойного перевыпуска.

{!liveReady ? ( Нет подключения к API Переключатель станет активен, когда backend доступен. Планировщик читает тот же флаг, что и страница «Сбор данных». ) : !confirmed ? (

Состояние автообновления пока неизвестно. Сохранение недоступно до успешного чтения.

) : enabled ? ( Не смешивайте с ACME RouterOS MM обновляет только сертификаты, выпущенные через эту страницу. Встроенный Let's Encrypt на устройстве для тех же имён лучше не включать одновременно. ) : ( Автообновление MM выключено Планировщик MM больше не проверяет срок и не перевыпускает сертификаты. Ручной выпуск и импорт остаются доступны. )}
{ dirty.current = true; setIntervalDraft(e.target.value) }} disabled={interactionsOff || !enabled} /> { dirty.current = true; setDaysDraft(e.target.value) }} disabled={interactionsOff || !enabled} />
Журнал планировщика →
{lastCollectedAt || lastError ? (

Последний прогон:{" "} {lastCollectedAt ? new Date(lastCollectedAt).toLocaleString("ru-RU") : "ещё не было"} {lastError ? ` · ошибка: ${lastError}` : ""}

) : null}
) }