feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,121 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { putAcmeCloudflareSettingsSchema, type AcmeCloudflareSettingsDto } from "@mmapp/contracts/certificates"
|
||||
import { getAcmeSettings, putAcmeSettings, testAcmeSettings } from "@/shared/api/certificates"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { toast } from "sonner"
|
||||
|
||||
/** Reuses Settings 16 composition: https://reui.io/preview/base/settings-16?ref=mcp */
|
||||
export function CertificateAcmeSettings({ backendUrl, liveReady, canRead, canWrite }: { backendUrl: string; liveReady: boolean; canRead: boolean; canWrite: boolean }) {
|
||||
const [confirmed, setConfirmed] = useState<AcmeCloudflareSettingsDto | null>(null)
|
||||
const [directory, setDirectory] = useState("")
|
||||
const [zone, setZone] = useState("")
|
||||
const [token, setToken] = useState("")
|
||||
const [loading, setLoading] = useState(liveReady && canRead)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [testResult, setTestResult] = useState<string | null>(null)
|
||||
const dirty = useRef(false)
|
||||
const lock = useRef(false)
|
||||
const alive = useRef(true)
|
||||
const controller = useRef<AbortController | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!liveReady || !canRead || lock.current || !alive.current) return
|
||||
controller.current?.abort()
|
||||
const abort = new AbortController()
|
||||
controller.current = abort
|
||||
setLoading(true)
|
||||
try {
|
||||
const saved = await getAcmeSettings(backendUrl, { signal: abort.signal })
|
||||
if (!alive.current || abort.signal.aborted) return
|
||||
setConfirmed(saved)
|
||||
if (!dirty.current) { setDirectory(saved.directoryUrl); setZone(saved.defaultZoneId ?? "") }
|
||||
setError(null)
|
||||
} catch (reason) {
|
||||
if (alive.current && !abort.signal.aborted) setError(reason instanceof Error ? reason.message : "Не удалось прочитать ACME настройки")
|
||||
} 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 operate(action: "save" | "test") {
|
||||
if (!liveReady || !canRead || !canWrite || !confirmed || loading || lock.current || !alive.current) return
|
||||
const payload = { directoryUrl: directory.trim(), defaultZoneId: zone.trim() || null, cloudflareApiToken: token.trim() || undefined }
|
||||
if (action === "save" && !putAcmeCloudflareSettingsSchema.safeParse(payload).success) { setError("Укажите корректный URL ACME directory."); return }
|
||||
if (action === "test" && !token.trim() && !confirmed.tokenConfigured) { setError("Введите токен для проверки. Сохранённого токена нет."); return }
|
||||
lock.current = true
|
||||
setBusy(true)
|
||||
setTestResult(null)
|
||||
const abort = new AbortController()
|
||||
controller.current = abort
|
||||
try {
|
||||
if (action === "save") {
|
||||
const saved = await putAcmeSettings(backendUrl, payload, { signal: abort.signal })
|
||||
if (!alive.current || abort.signal.aborted) return
|
||||
setConfirmed(saved)
|
||||
if (payload.cloudflareApiToken && !saved.tokenConfigured) throw new Error("Сохранение токена не подтверждено")
|
||||
setDirectory(saved.directoryUrl); setZone(saved.defaultZoneId ?? ""); setToken("")
|
||||
dirty.current = false
|
||||
setError(null)
|
||||
toast.success("Настройки ACME сохранены")
|
||||
} else {
|
||||
const result = await testAcmeSettings(backendUrl, { cloudflareApiToken: token.trim() || undefined }, { signal: abort.signal })
|
||||
if (!alive.current || abort.signal.aborted) return
|
||||
if (!result.ok) throw new Error(result.message ?? "Проверка Cloudflare не прошла")
|
||||
setError(null)
|
||||
setTestResult(result.message ?? "Cloudflare API доступен")
|
||||
}
|
||||
} catch (reason) {
|
||||
if (alive.current && !abort.signal.aborted) setError((reason instanceof Error ? reason.message : "Операция не выполнена") + (action === "save" ? ". Результат сохранения не подтверждён; черновик сохранён в форме." : ". Проверка не сохраняет настройки."))
|
||||
} finally {
|
||||
lock.current = false
|
||||
if (alive.current && !abort.signal.aborted) setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const disabled = !liveReady || !canRead || !canWrite || !confirmed || loading || busy
|
||||
function edit(set: (value: string) => void, value: string) { dirty.current = true; setTestResult(null); set(value) }
|
||||
return (
|
||||
<OpsPanel title="ACME · Cloudflare DNS-01" description="Публичные Let's Encrypt выпускаются на backend и импортируются на выбранный RouterOS 7.22+." contentClassName="px-5 py-4 flex flex-col gap-3">
|
||||
{error && <p role="alert" aria-label="Настройки ACME">{error}</p>}
|
||||
{loading && <p role="status">Загрузка настроек ACME…</p>}
|
||||
{!confirmed && <p role="status">Настройки ACME пока неизвестны. Сохранение и проверка недоступны до успешного чтения.</p>}
|
||||
{!liveReady && <p role="status">Нет подключения к API.</p>}
|
||||
{!canRead && <p role="status">Нет права mm:certificates:read.</p>}
|
||||
{!canWrite && <p role="status">Настройки ACME доступны только для чтения.</p>}
|
||||
<p className="text-xs text-muted-foreground">{confirmed ? confirmed.tokenConfigured ? "Токен сохранён на backend." : "Сохранённого токена нет." : "Состояние токена неизвестно."}</p>
|
||||
{testResult && <p role="status">{testResult}. Настройки не изменены.</p>}
|
||||
{liveReady && canRead && <Button variant="outline" size="sm" disabled={busy || loading} onClick={() => void load()}>Перечитать настройки ACME</Button>}
|
||||
<form className="grid gap-3 md:grid-cols-2" autoComplete="off" onSubmit={event => event.preventDefault()}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="certificate-acme-directory" className="text-sm font-medium">ACME directory URL</label>
|
||||
<Input id="certificate-acme-directory" name="acme-directory-url" autoComplete="off" disabled={disabled} value={directory} onChange={event => edit(setDirectory, event.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="certificate-acme-zone" className="text-sm font-medium">Cloudflare zone id (опционально)</label>
|
||||
<Input id="certificate-acme-zone" name="cloudflare-zone-id" autoComplete="off" disabled={disabled} value={zone} onChange={event => edit(setZone, event.target.value)} placeholder="Авто по домену" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 md:col-span-2">
|
||||
<label htmlFor="certificate-acme-token" className="text-sm font-medium">Cloudflare API token</label>
|
||||
<Input id="certificate-acme-token" name="cloudflare-api-token" type="password" autoComplete="new-password" disabled={disabled} value={token} onChange={event => edit(setToken, event.target.value)} placeholder={confirmed?.tokenConfigured ? "Введите новый для замены; пустое поле сохранит прежний" : "API token с правом DNS"} />
|
||||
<p className="text-xs text-muted-foreground">Проверка использует введённый токен или сохранённый на backend. Сохранение выполняется отдельно.</p>
|
||||
</div>
|
||||
</form>
|
||||
{canWrite && <div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" disabled={disabled} onClick={() => void operate("test")}>Проверить Cloudflare</Button>
|
||||
<Button size="sm" disabled={disabled} onClick={() => void operate("save")}>Сохранить настройки</Button>
|
||||
</div>}
|
||||
</OpsPanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user