fix(ui): сохранять подтверждённые настройки автообновления

This commit is contained in:
Denozordec committed 2026-10-01 13:56:42 +07:00
1 parent df89e88f4e
commit 1c407b77de
9 files changed
+182 -67

No files matched your search

@@ -1,6 +1,6 @@
"use client"
import { useCallback, useEffect, useState } from "react"
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"
@@ -13,6 +13,7 @@ import {
getCertificateRenewSettings,
putCertificateRenewSettings,
} from "@/shared/api/certificates"
import type { CertificateRenewSettingsDto } from "@mmapp/contracts/certificates"
import { toast } from "sonner"
/**
@@ -23,76 +24,95 @@ import { toast } from "sonner"
export function CertificateRenewSettingsPanel({
backendUrl,
liveReady,
canRead = true,
canWrite = true,
}: {
backendUrl: string
liveReady: boolean
canRead?: boolean
canWrite?: 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 [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) return
if (!liveReady || !canRead || writeLock.current || !alive.current) return
controller.current?.abort()
const abort = new AbortController()
controller.current = abort
setLoading(true)
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 : "Не удалось загрузить настройки автообновления")
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])
}, [backendUrl, liveReady, canRead])
useEffect(() => {
queueMicrotask(() => {
void load()
})
alive.current = true
const cancel = () => { alive.current = false; controller.current?.abort() }
return cancel
}, [])
useEffect(() => {
queueMicrotask(() => { if (alive.current) void load() })
}, [load])
async function handleEnabledChange(next: boolean) {
if (!liveReady || toggleBusy) return
const prev = enabled
setEnabled(next)
setToggleBusy(true)
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, { enabled: next })
setEnabled(saved.enabled)
toast.success(next ? "Автообновление через MikrotikManager включено" : "Автообновление через MikrotikManager выключено")
} catch (e) {
setEnabled(prev)
toast.error(e instanceof Error ? e.message : "Не удалось сохранить")
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 {
setToggleBusy(false)
writeLock.current = false
if (alive.current && !abort.signal.aborted) setBusy(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)
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 || toggleBusy || (liveReady && !loaded)
const interactionsOff = !liveReady || !canRead || !canWrite || busy || loading || !confirmed
const lastCollectedAt = confirmed?.lastCollectedAt
const lastError = confirmed?.lastError
return (
<OpsPanel
@@ -101,13 +121,18 @@ export function CertificateRenewSettingsPanel({
headerRight={
<Badge
size="sm"
variant={!liveReady ? "warning-light" : enabled ? "success-light" : "secondary"}
variant={!liveReady || !confirmed ? "warning-light" : enabled ? "success-light" : "secondary"}
>
{!liveReady ? "нет backend" : enabled ? "Включено" : "Выключено"}
{!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>
@@ -115,7 +140,7 @@ export function CertificateRenewSettingsPanel({
Если ACME уже крутит RouterOS — выключите, чтобы не было двойного перевыпуска.
</p>
</div>
<FormToggle checked={enabled} onChange={handleEnabledChange} disabled={interactionsOff} />
<FormToggle ariaLabel="Автообновление сертификатов" checked={enabled} onChange={handleEnabledChange} disabled={interactionsOff} />
</div>
{!liveReady ? (
@@ -125,6 +150,8 @@ export function CertificateRenewSettingsPanel({
Переключатель станет активен, когда backend доступен. Планировщик читает тот же флаг, что и страница «Сбор данных».
</AlertDescription>
</Alert>
) : !confirmed ? (
<p role="status">Состояние автообновления пока неизвестно. Сохранение недоступно до успешного чтения.</p>
) : enabled ? (
<Alert variant="warning">
<AlertTitle>Не смешивайте с ACME RouterOS</AlertTitle>
@@ -135,7 +162,7 @@ export function CertificateRenewSettingsPanel({
</Alert>
) : (
<Alert variant="info">
<AlertTitle>Обновление отдано RouterOS</AlertTitle>
<AlertTitle>Автообновление MM выключено</AlertTitle>
<AlertDescription>
Планировщик MM больше не проверяет срок и не перевыпускает сертификаты. Ручной выпуск и импорт
остаются доступны.
@@ -143,27 +170,29 @@ export function CertificateRenewSettingsPanel({
</Alert>
)}
<div className={cn("flex flex-col gap-4", !enabled && "pointer-events-none opacity-40")}>
<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}
onChange={(e) => setIntervalDraft(e.target.value)}
disabled={!liveReady || saveBusy}
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}
onChange={(e) => setDaysDraft(e.target.value)}
disabled={!liveReady || saveBusy}
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={!liveReady || saveBusy || !enabled} onClick={() => void handleSaveSchedule()}>
<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")}>