107 lines
9.3 KiB
TypeScript
107 lines
9.3 KiB
TypeScript
"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" headerRight={<div className="flex 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" 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>
|
||
}
|