Files
MikrotikManager/components/certificates/certificate-renew-settings.tsx
T

215 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<CertificateRenewSettingsDto | null>(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<string | null>(null)
const alive = useRef(true)
const controller = useRef<AbortController | null>(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 (
<OpsPanel
title="Автообновление через MikrotikManager"
description="Фоновый выпуск Let's Encrypt (Cloudflare DNS-01) для сертификатов, выпущенных из этой панели. Ручной выпуск не зависит от переключателя."
headerRight={
<Badge
size="sm"
variant={!liveReady || !confirmed ? "warning-light" : enabled ? "success-light" : "secondary"}
className={liveReady && confirmed && enabled ? "text-[color:var(--status-online-fg)]" : undefined}
>
{!confirmed ? "Неизвестно" : enabled ? "Включено" : "Выключено"}
</Badge>
}
contentClassName="px-5 py-4 flex flex-col gap-4"
>
{error && <p role="alert" aria-label="Настройки автообновления">{error}</p>}
{loading && <p role="status">Загрузка настроек автообновления…</p>}
{!canRead && <p role="status">Нет права mm:certificates:read.</p>}
{!canWrite && <p role="status">Настройки доступны только для чтения.</p>}
{liveReady && canRead && <Button variant="outline" size="sm" disabled={busy || loading} onClick={() => void load()}>Перечитать настройки автообновления</Button>}
<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 ariaLabel="Автообновление сертификатов" checked={enabled} onChange={handleEnabledChange} disabled={interactionsOff} />
</div>
{!liveReady ? (
<Alert variant="warning">
<AlertTitle>Нет подключения к API</AlertTitle>
<AlertDescription>
Переключатель станет активен, когда backend доступен. Планировщик читает тот же флаг, что и страница «Сбор данных».
</AlertDescription>
</Alert>
) : !confirmed ? (
<p role="status">Состояние автообновления пока неизвестно. Сохранение недоступно до успешного чтения.</p>
) : enabled ? (
<Alert variant="warning">
<AlertTitle>Не смешивайте с ACME RouterOS</AlertTitle>
<AlertDescription>
MM обновляет только сертификаты, выпущенные через эту страницу. Встроенный Let&apos;s Encrypt на
устройстве для тех же имён лучше не включать одновременно.
</AlertDescription>
</Alert>
) : (
<Alert variant="info">
<AlertTitle>Автообновление MM выключено</AlertTitle>
<AlertDescription>
Планировщик MM больше не проверяет срок и не перевыпускает сертификаты. Ручной выпуск и импорт
остаются доступны.
</AlertDescription>
</Alert>
)}
<div className={cn("flex flex-col gap-4", confirmed && !enabled && "opacity-60")}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<FormField label="Интервал проверки" hint="Секунды, минимум 300">
<Input
inputMode="numeric"
value={intervalDraft}
aria-label="Интервал проверки сертификатов"
onChange={(e) => { dirty.current = true; setIntervalDraft(e.target.value) }}
disabled={interactionsOff || !enabled}
/>
</FormField>
<FormField label="Обновлять за" hint="Дней до истечения, 1–90">
<Input
inputMode="numeric"
value={daysDraft}
aria-label="Дней до истечения сертификата"
onChange={(e) => { dirty.current = true; setDaysDraft(e.target.value) }}
disabled={interactionsOff || !enabled}
/>
</FormField>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" disabled={interactionsOff || !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>
)
}