215 lines
10 KiB
TypeScript
215 lines
10 KiB
TypeScript
"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'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>
|
||
)
|
||
}
|