Files
MikrotikManager/components/traffic/geoip-settings-section.tsx
T

108 lines
9.4 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 type { GeoipStatusDto } from "@mmapp/contracts/geoip"
import { can } from "@/lib/auth"
import { getGeoipStatus, putGeoipSettings, runGeoipUpdateNow } from "@/shared/api/geoip"
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 } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { AlertCircleIcon, DownloadIcon } from "lucide-react"
const message = (error: unknown) => error instanceof Error ? error.message : "Не удалось выполнить операцию GeoIP"
const date = (value: string | null | undefined) => value ? new Date(value).toLocaleString("ru-RU") : "—"
export function GeoipSettingsSection({ backendUrl }: { backendUrl: string }) {
return <GeoipSettingsContent key={backendUrl} backendUrl={backendUrl} />
}
function GeoipSettingsContent({ backendUrl }: { backendUrl: string }) {
const [status, setStatus] = useState<GeoipStatusDto | null>(null)
const [receivedAt, setReceivedAt] = useState<string | null>(null)
const [readError, setReadError] = useState<string | null>(null)
const [opError, setOpError] = useState<string | null>(null)
const [needsReview, setNeedsReview] = useState(false)
const [outcome, setOutcome] = useState<string | null>(null)
const [readBusy, setReadBusy] = useState(false)
const [busy, setBusy] = useState(false)
const [autoOn, setAutoOn] = useState(false)
const [intervalHours, setIntervalHours] = useState("")
const [dirty, setDirty] = useState(false)
const dirtyRef = useRef(false)
const lock = useRef(false)
const reader = useRef<{ active: boolean; revision: number; controller?: AbortController }>({ active: true, revision: 0 })
useEffect(() => {
const state = reader.current; state.active = true
return () => { state.active = false; state.revision++; state.controller?.abort() }
}, [])
const load = useCallback(async () => {
if (!reader.current.active || lock.current || !can("mm:traffic:read")) return
const revision = ++reader.current.revision; reader.current.controller?.abort()
const controller = new AbortController(); reader.current.controller = controller
const current = () => reader.current.active && reader.current.revision === revision && !controller.signal.aborted
setReadBusy(true)
try {
const data = await getGeoipStatus(backendUrl, { signal: controller.signal })
if (!current()) return
setStatus(data); setReadError(null); setReceivedAt(new Date().toISOString())
if (!dirtyRef.current) { setAutoOn(data.settings.enabled); setIntervalHours(String(data.settings.updateIntervalSec / 3600)) }
} catch (error) { if (current()) setReadError(message(error)) }
finally { if (current()) setReadBusy(false) }
}, [backendUrl])
useEffect(() => { queueMicrotask(() => { void load() }) }, [load])
const writable = can("mm:traffic:write") && Boolean(status) && !readError
function begin() {
if (!reader.current.active || lock.current || !writable || status?.running) return false
lock.current = true; reader.current.revision++; reader.current.controller?.abort(); setReadBusy(false); setBusy(true); setOpError(null); setOutcome(null)
return true
}
function finish() { lock.current = false; if (reader.current.active) setBusy(false) }
async function save() {
const hours = Number(intervalHours)
if (!/^(?:\d+)(?:\.\d+)?$/.test(intervalHours) || !Number.isSafeInteger(hours * 3600) || hours < 6 || hours > 720) { setOpError("Укажите интервал от 6 до 720 часов с точностью до секунды"); return }
if (!dirty || !begin()) return
try {
const data = await putGeoipSettings(backendUrl, { enabled: autoOn, updateIntervalSec: hours * 3600 })
if (!reader.current.active) return
setStatus(data.status); setReceivedAt(new Date().toISOString()); dirtyRef.current = false; setDirty(false)
setOutcome("Сохранение настроек GeoIP подтверждено")
} catch (error) { if (reader.current.active) setOpError(message(error)) }
finally { finish() }
}
async function update() {
if (needsReview || !begin()) return
try {
const data = await runGeoipUpdateNow(backendUrl)
if (!reader.current.active) return
const snap = data.snapshot
setOutcome(snap.skipped ? "Прогон GeoIP пропущен" : data.ok ? "Обновление GeoIP подтверждено" : "Обновление GeoIP завершилось частично или с ошибками")
if (snap.errors.length || snap.fatalError) setOpError([snap.fatalError, ...snap.errors].filter(Boolean).join("; "))
setOutcome(prev => prev + `. Проверено: ${snap.checked}; скачано: ${snap.downloaded}; без изменений: ${snap.skippedUnchanged}; байт: ${snap.bytes}.`)
} catch (error) { if (reader.current.active) { setNeedsReview(true); setOpError(message(error) + ". Результат операции неизвестен; обновите статус перед повтором.") } }
finally { finish() }
if (reader.current.active) await load()
}
const markDirty = () => { dirtyRef.current = true; setDirty(true) }
return <OpsPanel title="GeoIP-базы (GeoLite2)" description="Страны и ASN потоков определяются по локальным базам MaxMind GeoLite2 с зеркала P3TERX." contentClassName="px-5 py-4 flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2"><Badge variant={status?.countryLoaded ? "success" : "secondary"}>Country: {status ? status.countryLoaded ? "загружена" : "не загружена" : "—"}</Badge><Badge variant={status?.asnLoaded ? "success" : "secondary"}>ASN: {status ? status.asnLoaded ? "загружена" : "не загружена" : "—"}</Badge></div>
{!can("mm:traffic:read") ? <p role="status">Нет доступа к данным GeoIP</p> : <p role="status" className="text-xs text-muted-foreground">{readBusy ? "Загрузка GeoIP…" : status ? "Статус GeoIP получен: " + date(receivedAt) : "Статус GeoIP не получен"}</p>}
{readError ? <Alert variant="warning"><AlertCircleIcon aria-hidden="true" /><AlertTitle>{status ? "GeoIP не обновлён" : "GeoIP не получен"}</AlertTitle><AlertDescription>{readError}{status ? ". Показаны последние подтверждённые данные: " + date(receivedAt) : ""}</AlertDescription></Alert> : null}
{opError ? <Alert variant="destructive"><AlertCircleIcon aria-hidden="true" /><AlertTitle>Операция GeoIP требует внимания</AlertTitle><AlertDescription>{opError}</AlertDescription></Alert> : null}
{needsReview ? <div className="flex flex-col gap-2"><p className="text-xs text-muted-foreground">Проверьте журнал планировщика и состояние баз. Повторное обновление запустит новую операцию.</p><Button type="button" size="sm" variant="outline" aria-label="Разрешить новое обновление GeoIP" disabled={!writable || busy || readBusy || status?.running} onClick={() => { setNeedsReview(false); setOpError(null) }}>Разрешить повтор GeoIP</Button></div> : null}
{outcome ? <p role="status" className="text-sm">{outcome}</p> : null}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex items-center gap-3"><FormToggle ariaLabel="Автообновление GeoIP" checked={autoOn} disabled={!writable || busy} onChange={value => { markDirty(); setAutoOn(value) }} /><span className="text-sm">Автообновление{!status ? ": —" : ""}</span></div>
<FormField label="Интервал проверки (часов)" hint="От 6 до 720 часов"><Input aria-label="Интервал GeoIP в часах" className="font-mono" value={intervalHours} onChange={event => { markDirty(); setIntervalHours(event.target.value) }} inputMode="decimal" disabled={!writable || busy} /></FormField>
</div>
<p className="text-xs text-muted-foreground">Сборка Country: {date(status?.settings.countryBuildAt)} · ASN: {date(status?.settings.asnBuildAt)} · проверка: {date(status?.settings.lastCheckAt)}{status?.settings.lastError ? " · ошибка: " + status.settings.lastError : ""}</p>
<p className="text-xs text-muted-foreground">Каталог: <span className="font-mono break-all">{status?.dir ?? "—"}</span>. До загрузки базы и при отсутствии записи используется RIPEstat.</p>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" disabled={!writable || busy || !dirty || status?.running} onClick={() => { void save() }}>Сохранить GeoIP</Button>
<Button type="button" size="sm" variant="outline" disabled={!writable || busy || needsReview || status?.running} onClick={() => { void update() }}><DownloadIcon aria-hidden="true" className="size-4" />Обновить сейчас</Button>
<Button type="button" size="sm" variant="outline" disabled={busy || readBusy || !can("mm:traffic:read")} onClick={() => { void load() }}>Обновить статус GeoIP</Button>
</div>
<p className="text-xs text-muted-foreground">Данные: MaxMind GeoLite2 (CC BY-SA 4.0), зеркало P3TERX/GeoLite.mmdb.</p>
</OpsPanel>
}