64 lines
5.0 KiB
TypeScript
64 lines
5.0 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
import { can } from "@/lib/auth"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
|
import { AlertCircleIcon } from "lucide-react"
|
|
import { getTrafficFlowHostStatus, reconcileTrafficFlowHost } from "@/shared/api/traffic-flow"
|
|
import type { TrafficFlowHostStatus } from "@mmapp/contracts/traffic-flow"
|
|
|
|
export function FlowHostPanel({ backendUrl, enabled = true, onConfigured }: {
|
|
backendUrl: string; enabled?: boolean; onConfigured?: (configured: boolean) => void
|
|
}) {
|
|
const [status, setStatus] = useState<TrafficFlowHostStatus | null>(null)
|
|
const [receivedAt, setReceivedAt] = useState<number | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [operationError, setOperationError] = useState<string | null>(null)
|
|
const [busy, setBusy] = useState(false)
|
|
const operation = useRef<AbortController | null>(null)
|
|
const [revision, setRevision] = useState(0)
|
|
const accept = useCallback((value: TrafficFlowHostStatus) => {
|
|
setStatus(value); setReceivedAt(Date.now()); setError(null); onConfigured?.(value.configured)
|
|
}, [onConfigured])
|
|
useEffect(() => () => { operation.current?.abort() }, [])
|
|
useEffect(() => {
|
|
if (!enabled || !can("mm:traffic:read") || busy) return
|
|
const controller = new AbortController()
|
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
async function refresh() {
|
|
try { const value = await getTrafficFlowHostStatus(backendUrl, { signal: controller.signal }); if (!controller.signal.aborted) accept(value) }
|
|
catch (reason) { if (!controller.signal.aborted) setError(reason instanceof Error ? reason.message : "Статус хоста не получен") }
|
|
finally { if (!controller.signal.aborted) timer = setTimeout(() => { void refresh() }, 10_000) }
|
|
}
|
|
void refresh()
|
|
return () => { controller.abort(); if (timer) clearTimeout(timer) }
|
|
}, [accept, backendUrl, enabled, busy, revision])
|
|
|
|
async function apply() {
|
|
if (operation.current) return
|
|
const controller = new AbortController(); operation.current = controller
|
|
setBusy(true); setOperationError(null)
|
|
try { const value = await reconcileTrafficFlowHost(backendUrl, { signal: controller.signal }); if (!controller.signal.aborted) accept(value) }
|
|
catch (reason) { if (!controller.signal.aborted) setOperationError(reason instanceof Error ? reason.message : "Применение не подтверждено; обновите состояние хоста") }
|
|
finally { operation.current = null; if (!controller.signal.aborted) setBusy(false) }
|
|
}
|
|
if (!enabled || !can("mm:traffic:read")) return null
|
|
return <div className="flex flex-col gap-3 rounded-md border px-4 py-3" aria-label="Автоматизация WireGuard на хосте">
|
|
<p className="text-sm font-medium">WireGuard на сервере MM</p>
|
|
<p role="status" className="text-sm">{!status ? "Получение состояния агента…" : !status.configured ? "Ручной режим. Установите агент из раздела «Файлы хоста»." : !status.reachable ? "Агент недоступен" : status.configMatches ? "Настройки применены" : "Настройки требуют проверки или применения"}</p>
|
|
{error && receivedAt ? <p className="text-xs text-muted-foreground">Показано последнее состояние: {new Date(receivedAt).toLocaleString("ru-RU")}</p> : null}
|
|
{status?.configured && status.reachable ? <>
|
|
<p className="text-xs text-muted-foreground">Интерфейс: {status.interfaceUp ? status.addresses.join(", ") : "не поднят"} · UDP {status.listenPort}</p>
|
|
<ul className="flex flex-col gap-1 text-xs text-muted-foreground" aria-label="Handshake пиров">
|
|
{status.peers.map(peer => <li key={peer.publicKey}>{peer.allowedIps.join(", ") || "Без адреса"} · {peer.lastHandshakeAt ? "Handshake: " + new Date(peer.lastHandshakeAt * 1000).toLocaleString("ru-RU") + ((receivedAt ?? 0) / 1000 - peer.lastHandshakeAt > 180 ? " (давно)" : "") : "Ожидание handshake"}</li>)}
|
|
</ul>
|
|
</> : null}
|
|
{operationError || error || status?.error ? <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Состояние хоста требует внимания</AlertTitle><AlertDescription>{operationError || error || status?.error}</AlertDescription></Alert> : null}
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => setRevision(value => value + 1)}>Обновить состояние хоста</Button>
|
|
{status?.configured && can("mm:traffic:write") ? <Button type="button" size="sm" disabled={busy || !status.reachable} onClick={() => { void apply() }}>{busy ? "Применение…" : "Применить настройки хоста"}</Button> : null}
|
|
</div>
|
|
</div>
|
|
}
|