776 lines
101 KiB
TypeScript
776 lines
101 KiB
TypeScript
"use client"
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"
|
||
import Link from "next/link"
|
||
import { useSearchParams } from "next/navigation"
|
||
import { useTable, type ColumnDef } from "@tanstack/react-table"
|
||
import { BotIcon, CopyIcon, PlusIcon, RefreshCwIcon, ShieldIcon, StopCircleIcon } from "lucide-react"
|
||
import { toast } from "sonner"
|
||
import { mcpConnectionCreateSchema, mcpPageQuerySchema, mcpTargetSchema, type McpConnection, type McpOperationDescriptor, type McpPolicyDecision } from "@mmapp/contracts/mcp"
|
||
import { routeAiControllerBlockReason, routeAiProfileSchema, type RouteAiProfile } from "@mmapp/contracts/mcp-routing"
|
||
import { useDataSource } from "@/lib/data-source"
|
||
import { can, getClaims, hasPermission } from "@/lib/auth"
|
||
import { DATA_GRID_LABELS_RU } from "@/lib/data-grid-labels-ru"
|
||
import { mcpApi, type McpApi } from "@/shared/api/mcp"
|
||
import { PageHeader } from "@/components/page-header"
|
||
import { DatabaseRestore } from "./database-restore"
|
||
import { PlanReview } from "./plan-review"
|
||
import { ArtifactDownloads } from "./artifact-downloads"
|
||
import { PollStorageCapacity } from "./poll-storage-capacity"
|
||
import { BridgeVlanWorkflow } from "./bridge-vlan-workflow"
|
||
import { ObjectPermissionPreview, PolicyPreviewDecision } from "./object-permission-preview"
|
||
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame"
|
||
import { DataGrid, DataGridContainer, DataGridTable, DataGridScrollArea, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||
import { Badge } from "@/components/reui/badge"
|
||
import { Button, buttonVariants } from "@/components/ui/button"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Checkbox } from "@/components/ui/checkbox"
|
||
import { Textarea } from "@/components/ui/textarea"
|
||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||
|
||
const errorText = (error: unknown) => error instanceof Error ? error.message : "Не удалось выполнить запрос"
|
||
const modeLabels = { observer: "Наблюдатель", approval: "С подтверждением", autonomous: "Автономный" }
|
||
const tabs = { overview: "Обзор", connections: "Подключения", permissions: "Разрешения", devices: "Интерфейсы", requests: "Обращения", plans: "Планы и операции", routing: "Route AI", settings: "Настройки" }
|
||
type Tab = keyof typeof tabs
|
||
type RecordRow = Record<string, unknown>
|
||
function pretty(value: unknown) { return JSON.stringify(value, null, 2) }
|
||
function date(value: unknown) { return value ? new Date(String(value)).toLocaleString("ru-RU") : "—" }
|
||
function connectionAccessDeadline(connection: McpConnection) {
|
||
return Math.min(Date.parse(connection.policy.expiresAt), Date.parse(connection.ownerValidUntil ?? ""))
|
||
}
|
||
function ConnectionAccessStatus({ connection }: { connection: McpConnection }) {
|
||
const [now, setNow] = useState(() => Date.now())
|
||
const expires = Date.parse(connection.policy.expiresAt), ownerExpires = Date.parse(connection.ownerValidUntil ?? "")
|
||
useEffect(() => {
|
||
let timer: number | undefined
|
||
const tick = () => {
|
||
const current = Date.now(); setNow(current)
|
||
const next = [expires, ownerExpires].filter(value => Number.isFinite(value) && value > current).reduce((first, value) => Math.min(first, value), Infinity)
|
||
if (Number.isFinite(next)) timer = window.setTimeout(tick, Math.min(60000, Math.max(1, next - current + 1)))
|
||
}
|
||
tick()
|
||
return () => window.clearTimeout(timer)
|
||
}, [expires, ownerExpires])
|
||
const [label, reason] = connection.policy.status === "revoked" ? ["Доступ отозван", "Подключение и его токены отозваны"]
|
||
: connection.policy.status === "paused" ? ["На паузе", "Новые вызовы подключения остановлены"]
|
||
: !connection.generationValid ? ["Доступ аннулирован", "Поколение доступа больше не действительно"]
|
||
: !Number.isFinite(expires) ? ["Срок не подтверждён", "Не удалось определить срок доступа"]
|
||
: expires <= now ? ["Срок доступа истёк", "Продлите срок в разрешениях подключения"]
|
||
: !Number.isFinite(ownerExpires) ? ["Права не подтверждены", "Срок подтверждения прав владельца отсутствует"]
|
||
: ownerExpires <= now ? ["Права владельца истекли", "Владельцу нужно подтвердить актуальные права"]
|
||
: ["Активен", "Срок доступа и права владельца действуют"]
|
||
return <div className="flex flex-col gap-1"><Badge variant={label === "Активен" ? "success-light" : "warning-light"}>{label}</Badge><span className="text-xs text-muted-foreground">{reason}</span></div>
|
||
}
|
||
function useQuery<T>(load: (signal: AbortSignal) => Promise<T>, key = "") {
|
||
const [response, setResponse] = useState<{ key: string; data: T; updatedAt: string } | null>(null)
|
||
const [failure, setFailure] = useState<{ key: string; message: string } | null>(null)
|
||
const refresh = useCallback(() => { window.dispatchEvent(new Event("mcp-refresh")) }, [])
|
||
useEffect(() => {
|
||
let alive = true, revision = 0, pending = false, controller: AbortController | undefined, deadline: number | undefined
|
||
const run = async (replace = false) => {
|
||
if (!alive || document.hidden || (pending && !replace)) return
|
||
controller?.abort(); window.clearTimeout(deadline); controller = new AbortController(); const requestRevision = ++revision
|
||
pending = true
|
||
deadline = window.setTimeout(() => {
|
||
if (!alive || requestRevision !== revision) return
|
||
revision++; pending = false; controller?.abort()
|
||
setFailure({ key, message: "Обновление не завершилось за минуту. Следующая попытка выполнится автоматически." })
|
||
}, 60000)
|
||
try {
|
||
const result = await load(controller.signal)
|
||
if (alive && requestRevision === revision) {
|
||
setResponse({ key, data: result, updatedAt: new Date().toISOString() })
|
||
setFailure(null)
|
||
}
|
||
} catch (error) {
|
||
if (alive && requestRevision === revision && !controller.signal.aborted) setFailure({ key, message: errorText(error) })
|
||
}
|
||
finally { if (requestRevision === revision) { window.clearTimeout(deadline); pending = false } }
|
||
}
|
||
queueMicrotask(() => { void run() })
|
||
const tick = () => { void run() }, timer = window.setInterval(tick, 5000)
|
||
const reload = () => { void run(true) }
|
||
window.addEventListener("mcp-refresh", reload); document.addEventListener("visibilitychange", tick)
|
||
return () => { alive = false; revision++; controller?.abort(); window.clearTimeout(deadline); window.clearInterval(timer); window.removeEventListener("mcp-refresh", reload); document.removeEventListener("visibilitychange", tick) }
|
||
}, [load, key])
|
||
return {
|
||
data: response?.key === key ? response.data : null,
|
||
error: failure?.key === key ? failure.message : "",
|
||
refresh,
|
||
updatedAt: response?.key === key ? response.updatedAt : null,
|
||
}
|
||
}
|
||
function Panel({ title, description, children, actions }: { title: string; description?: string; children: ReactNode; actions?: ReactNode }) {
|
||
return <Frame dense><FrameHeader className="flex-row flex-wrap items-center justify-between gap-3"><div><FrameTitle>{title}</FrameTitle>{description && <FrameDescription>{description}</FrameDescription>}</div>{actions}</FrameHeader><FramePanel className="flex min-w-0 flex-col gap-4 p-4">{children}</FramePanel></Frame>
|
||
}
|
||
function ErrorMessage({ message }: { message: string }) { return message ? <p role="alert" className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{message}</p> : null }
|
||
function Field({ label, children }: { label: string; children: ReactNode }) { return <label className="flex min-w-0 flex-col gap-1.5 text-sm"><span>{label}</span>{children}</label> }
|
||
function Choice({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) { return <label className="flex items-center gap-2 text-sm"><Checkbox checked={checked} onCheckedChange={checked => onChange(!!checked)} />{label}</label> }
|
||
function NativeSelect({ label, value, onChange, children }: { label: string; value: string; onChange: (value: string) => void; children: ReactNode }) {
|
||
return <Field label={label}><select aria-label={label} className="h-9 min-w-0 rounded-md border border-input bg-background px-2 text-sm" value={value} onChange={event => onChange(event.target.value)}>{children}</select></Field>
|
||
}
|
||
function Grid({ items, columns, loading, total, onSelect }: { items: RecordRow[]; columns: ColumnDef<DataGridFeatures, RecordRow>[]; loading?: boolean; total?: number; onSelect?: (row: RecordRow) => void }) {
|
||
const table = useTable({ features: dataGridFeatures, data: items, columns, getRowId: (row, index) => String(row.id ?? index), manualPagination: true, rowCount: total ?? items.length })
|
||
return <DataGrid table={table} recordCount={total ?? items.length} isLoading={loading} onRowClick={onSelect} i18n={{ labels: DATA_GRID_LABELS_RU }} emptyMessage="Записей пока нет" tableLayout={{ dense: true, width: "fixed" }}><DataGridContainer><DataGridScrollArea className="max-h-[32rem]"><DataGridTable /></DataGridScrollArea></DataGridContainer></DataGrid>
|
||
}
|
||
type DeviceResource = Awaited<ReturnType<McpApi["deviceCatalog"]>>["resources"][number]
|
||
function DeviceManagement({ api }: { api: McpApi }) {
|
||
const connections = useQuery(useCallback(signal => api.connections(signal), [api]))
|
||
const [connectionId, setConnectionId] = useState("")
|
||
return <Panel title="Интерфейсы и адреса" description="Чтение и подготовка изменений от имени выбранного подключения. Подтверждение и применение — на вкладке «Планы и операции»."><ErrorMessage message={connections.error} /><NativeSelect label="Подключение для управления интерфейсами" value={connectionId} onChange={setConnectionId}><option value="">Выберите подключение</option>{connections.data?.connections.filter(connection => connection.ownerId === getClaims()?.sub && connection.policy.status === "active").map(connection => <option key={connection.id} value={connection.id}>{connection.name}</option>)}</NativeSelect>{connectionId && <DeviceCatalog key={connectionId} api={api} connectionId={connectionId} />}</Panel>
|
||
}
|
||
function DeviceCatalog({ api, connectionId }: { api: McpApi; connectionId: string }) {
|
||
const catalog = useQuery(useCallback(signal => api.deviceCatalog(connectionId, signal), [api, connectionId]))
|
||
const [serverId, setServerId] = useState(""), [resourceId, setResourceId] = useState("")
|
||
const resource = catalog.data?.resources.find(resource => resource.id === resourceId)
|
||
return <><ErrorMessage message={catalog.error} /><div className="grid gap-3 sm:grid-cols-2"><NativeSelect label="Сервер интерфейсов" value={serverId} onChange={setServerId}><option value="">Выберите сервер</option>{catalog.data?.servers.map(server => <option key={server.id} value={String(server.id)} disabled={!server.enabled}>{server.name} · {server.host}</option>)}</NativeSelect><NativeSelect label="Тип объектов устройства" value={resourceId} onChange={setResourceId}><option value="">Выберите тип</option>{catalog.data?.resources.filter(resource => resource.read).map(resource => <option key={resource.id} value={resource.id}>{resource.title}</option>)}</NativeSelect></div>{catalog.data && !catalog.data.resources.some(resource => resource.read) && <p className="text-sm text-muted-foreground">Подключению не выданы инструменты чтения интерфейсов.</p>}{resourceId === "bridge" && serverId && catalog.data?.servers.some(server => String(server.id) === serverId && server.enabled) && <BridgeVlanWorkflow key={`vlan:${connectionId}:${serverId}`} api={api} connectionId={connectionId} serverId={serverId} />}{resource && serverId && catalog.data?.servers.some(server => String(server.id) === serverId && server.enabled) && <DeviceEditor key={`${serverId}:${resourceId}`} api={api} connectionId={connectionId} serverId={serverId} resource={resource} />}</>
|
||
}
|
||
function DeviceEditor({ api, connectionId, serverId, resource }: { api: McpApi; connectionId: string; serverId: string; resource: DeviceResource }) {
|
||
const [offset, setOffset] = useState(0), [editing, setEditing] = useState<RecordRow | "new" | null>(null), [draft, setDraft] = useState<Record<string, unknown>>({})
|
||
const [busy, setBusy] = useState(false), [error, setError] = useState(""), [receipt, setReceipt] = useState("")
|
||
const rows = useQuery(useCallback(signal => api.deviceRows(connectionId, serverId, resource.id, offset, signal), [api, connectionId, serverId, resource.id, offset]))
|
||
const open = (row: RecordRow | "new") => { setEditing(row); setDraft({}); setError(""); setReceipt("") }
|
||
const prepare = async (action: "create" | "update" | "delete") => {
|
||
setBusy(true); setError(""); setReceipt("")
|
||
try {
|
||
const result = await api.devicePlan(connectionId, serverId, resource.id, action, editing && editing !== "new" ? String(editing[".id"]) : undefined, action === "delete" ? {} : draft)
|
||
setReceipt(`Создан план ${result.planId}. Проверьте изменения, подтвердите и примените его на вкладке «Планы и операции».`); setEditing(null); rows.refresh()
|
||
} catch (failure) { setError(errorText(failure)) } finally { setBusy(false) }
|
||
}
|
||
const current = editing && editing !== "new" ? editing : null
|
||
const ethernetDisabledUnknown = resource.id === "ethernet" && current !== null && current.disabled === undefined
|
||
const editableFields = resource.fields.filter(field =>
|
||
editing === "new" || field.key === "comment" || field.key in (current ?? {}) || (ethernetDisabledUnknown && field.key === "disabled"),
|
||
)
|
||
const protectedRow = current && ["dynamic", "builtin", "default"].some(key => current[key] === "true" || current[key] === true)
|
||
const change = (key: string, value: unknown) => setDraft(fields => { const next = { ...fields }; if (value === undefined) delete next[key]; else next[key] = value; return next })
|
||
return <><ErrorMessage message={rows.error || error} />{receipt && <p role="status" className="text-sm">{receipt}</p>}<div className="flex flex-wrap items-center justify-between gap-2"><p className="text-sm text-muted-foreground">Объектов: {rows.data?.total ?? "…"}. Выберите строку для просмотра и подготовки изменения.</p>{resource.create && <Button variant="outline" disabled={busy} onClick={() => open("new")}><PlusIcon />Создать объект</Button>}</div><Grid items={rows.data?.items ?? []} loading={!rows.data && !rows.error} total={rows.data?.total} columns={[{ id: "nativeId", header: "ID", cell: ({ row }) => String(row.original[".id"] ?? "—") }, { id: "name", header: "Объект", cell: ({ row }) => String(row.original.name ?? row.original.address ?? row.original.interface ?? "—") }, { id: "state", header: "Состояние", cell: ({ row }) => String(row.original.disabled === "true" || row.original.disabled === true ? "Выключен" : row.original.running ?? "—") }]} onSelect={row => { if (row[".id"] && !busy) open(row) }} /><div className="flex gap-2"><Button variant="outline" disabled={busy || offset === 0} onClick={() => { setOffset(Math.max(0, offset - 100)); setEditing(null) }}>Предыдущие объекты</Button><Button variant="outline" disabled={busy || rows.data?.nextOffset == null} onClick={() => { if (rows.data?.nextOffset != null) setOffset(rows.data.nextOffset); setEditing(null) }}>Следующие объекты</Button></div>{editing && <div className="flex flex-col gap-4 rounded-md border p-4"><p className="font-medium">{editing === "new" ? "Новый объект" : `Объект ${String(current?.[".id"])}`}</p>{current && <details><summary className="cursor-pointer text-sm">Текущие свойства</summary><pre className="max-h-60 overflow-auto whitespace-pre-wrap break-all text-xs">{pretty(current)}</pre></details>}{protectedRow ? <p className="text-sm">Системный или динамический объект доступен для чтения.</p> : <><p className="text-xs text-muted-foreground">В план попадут только заполненные изменения. Пустой выбор оставляет свойство без изменения; при создании действует настройка устройства.</p><div className="grid gap-3 sm:grid-cols-2">{editableFields.map(field => <Field key={field.key} label={`${field.key}${editing === "new" && field.requiredOnCreate ? " *" : ""}`}>{field.type === "boolean" || field.options ? <select aria-label={field.key} className="h-9 min-w-0 rounded-md border border-input bg-background px-2 text-sm" value={String(draft[field.key] ?? "")} disabled={busy || editing !== "new" && !resource.update} onChange={event => change(field.key, event.target.value === "" ? undefined : field.type === "boolean" ? event.target.value === "true" : event.target.value)}><option value="">{ethernetDisabledUnknown && field.key === "disabled" ? "Текущее значение неизвестно" : current ? `Сейчас: ${String(current[field.key])}` : "По умолчанию устройства"}</option>{(field.options ?? ["true", "false"]).map(value => <option key={value} value={value}>{value}</option>)}</select> : <Input aria-label={field.key} type={field.type === "number" ? "number" : "text"} min={field.min} max={field.max} maxLength={field.maxLength} disabled={busy || editing !== "new" && !resource.update} placeholder={current ? String(current[field.key] ?? "") : ""} value={String(draft[field.key] ?? "")} onChange={event => change(field.key, field.type === "number" ? event.target.value === "" ? undefined : Number(event.target.value) : event.target.value)} />}</Field>)}</div>
|
||
{ethernetDisabledUnknown && draft.disabled !== undefined && <p className="text-sm text-muted-foreground">Исходное состояние неизвестно. Для этого изменения обратный план недоступен.</p>}
|
||
</>}<div className="flex flex-wrap gap-2">{!protectedRow && (editing === "new" ? resource.create : resource.update) && <Button disabled={busy || !Object.keys(draft).length} onClick={() => { void prepare(editing === "new" ? "create" : "update") }}>Подготовить план изменения</Button>}{current && !protectedRow && resource.delete && <Button variant="destructive" disabled={busy} onClick={() => { void prepare("delete") }}>Подготовить план удаления</Button>}<Button variant="outline" disabled={busy} onClick={() => setEditing(null)}>Закрыть объект</Button></div></div>}</>
|
||
}
|
||
function TokenDialog({ token, close, connectionName }: { token: string; close: () => void; connectionName?: string }) {
|
||
return <Dialog open={!!token} onOpenChange={open => { if (!open) close() }}><DialogContent className="sm:max-w-xl"><DialogHeader><DialogTitle>Доступ создан</DialogTitle><DialogDescription>{connectionName && `Подключение «${connectionName}». `}Сохраните токен в настройках AI-клиента. Повторно посмотреть его нельзя.</DialogDescription></DialogHeader><Input aria-label="Одноразовый токен MCP" readOnly type="password" value={token} /><Button onClick={() => { void navigator.clipboard.writeText(token).then(() => toast.success("Токен скопирован"), () => toast.error("Не удалось скопировать токен")) }}><CopyIcon />Копировать</Button><Button variant="outline" onClick={close}>Закрыть и скрыть токен</Button></DialogContent></Dialog>
|
||
}
|
||
function ConnectionForm({ api, operations, editing, close, created }: { api: McpApi; operations: McpOperationDescriptor[]; editing?: McpConnection; close: () => void; created: (token?: string) => void }) {
|
||
const [step, setStep] = useState(1), [busy, setBusy] = useState(false), [error, setError] = useState("")
|
||
const [name, setName] = useState(editing?.name ?? ""), [mode, setMode] = useState<McpConnection["policy"]["mode"]>(editing?.policy.mode ?? "observer")
|
||
const [serverText, setServerText] = useState(editing?.policy.scope.serverIds.join(", ") ?? ""), [selected, setSelected] = useState<string[]>(editing?.policy.scope.operationIds ?? [])
|
||
const [global, setGlobal] = useState(editing?.policy.scope.allowGlobal ?? false), [danger, setDanger] = useState(editing?.policy.dangerousPolicy ?? "approval")
|
||
const [dangerIds, setDangerIds] = useState(editing?.policy.autonomousDangerousOperationIds ?? [])
|
||
const [expires, setExpires] = useState(() => editing?.policy.expiresAt.slice(0, 16) ?? new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 16))
|
||
const [limits, setLimits] = useState(editing?.policy.limits ?? { callsPerMinute: 60, concurrentReads: 4, mutationsPerHour: 10, serversPerPlan: 5, objectsPerPlan: 100 })
|
||
const permissions = getClaims()?.permissions ?? []
|
||
const eligible = operations.filter(operation => operation.implementation === "available" && operation.requiredPermissions.every(permission => hasPermission(permissions, permission)))
|
||
const toggle = (id: string) => setSelected(ids => ids.includes(id) ? ids.filter(value => value !== id) : [...ids, id])
|
||
const save = async () => {
|
||
setBusy(true); setError("")
|
||
try {
|
||
const input = mcpConnectionCreateSchema.parse({ name, mode, expiresAt: new Date(expires + "Z").toISOString(), permissions: [...new Set(eligible.filter(operation => selected.includes(operation.id)).flatMap(operation => operation.requiredPermissions))], scope: { serverIds: serverText.split(/[,\s]+/).filter(Boolean).map(Number), operationIds: selected, allowGlobal: global }, dangerousPolicy: danger, autonomousDangerousOperationIds: dangerIds.filter(id => selected.includes(id)), limits })
|
||
if (editing) { await api.update(editing.id, input); created() }
|
||
else { const result = await api.create(input); created(result.token) }
|
||
} catch (failure) { setError(errorText(failure)) } finally { setBusy(false) }
|
||
}
|
||
return <Dialog open onOpenChange={open => { if (!open && !busy) close() }}><DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-3xl"><DialogHeader><DialogTitle>{editing ? "Изменить разрешения" : "Новое подключение"}</DialogTitle><DialogDescription>Шаг {step} из 4 · доступ ограничивается правами текущего владельца.</DialogDescription></DialogHeader><ErrorMessage message={error} />
|
||
{step === 1 && <><Field label="Название"><Input value={name} onChange={event => setName(event.target.value)} placeholder="Агент диагностики" /></Field><Field label="Срок действия (UTC)"><Input type="datetime-local" value={expires} onChange={event => setExpires(event.target.value)} /></Field><NativeSelect label="Режим управления" value={mode} onChange={value => setMode(value as typeof mode)}>{Object.entries(modeLabels).map(([value, title]) => <option key={value} value={value}>{title}</option>)}</NativeSelect></>}
|
||
{step === 2 && <><Field label="ID разрешённых серверов через запятую"><Input value={serverText} onChange={event => setServerText(event.target.value)} placeholder="1, 2" /></Field><Choice label="Разрешить выбранные глобальные операции" checked={global} onChange={setGlobal} /><div className="grid max-h-80 gap-2 overflow-y-auto sm:grid-cols-2">{eligible.map(operation => <Choice key={operation.id} label={operation.title} checked={selected.includes(operation.id)} onChange={() => toggle(operation.id)} />)}</div><p className="text-xs text-muted-foreground">Новые инструменты не добавляются в доступ автоматически. Для применения планов выберите также инструменты просмотра и применения.</p></>}
|
||
{step === 3 && <><div className="grid gap-3 sm:grid-cols-2">{Object.entries(limits).map(([key, value]) => <Field key={key} label={({ callsPerMinute: "Вызовов в минуту", concurrentReads: "Одновременных чтений", mutationsPerHour: "Изменений в час", serversPerPlan: "Серверов на план", objectsPerPlan: "Объектов на план" } as Record<string, string>)[key]}><Input type="number" min={1} value={value} onChange={event => setLimits(current => ({ ...current, [key]: Number(event.target.value) }))} /></Field>)}</div><NativeSelect label="Опасные действия" value={danger} onChange={value => setDanger(value as typeof danger)}><option value="approval">Всегда подтверждать</option><option value="explicit-allow">Автономно для явно выбранных действий</option></NativeSelect>{danger === "explicit-allow" && eligible.filter(operation => operation.risk === "dangerous" && selected.includes(operation.id)).map(operation => <Choice key={operation.id} label={operation.title} checked={dangerIds.includes(operation.id)} onChange={checked => setDangerIds(ids => checked ? [...ids, operation.id] : ids.filter(id => id !== operation.id))} />)}</>}
|
||
{step === 4 && <div className="space-y-3 text-sm"><p><strong>{name || "Без названия"}</strong> · {modeLabels[mode]}</p><p>Серверы: {serverText || "не выбраны"}. Операций: {selected.length}. Глобальный доступ: {global ? "да" : "нет"}.</p><p>Защита объектов продолжает действовать независимо от автономных разрешений.</p><p>После сохранения проверьте конкретное действие в симуляторе на вкладке «Разрешения».</p></div>}
|
||
<div className="flex flex-wrap justify-between gap-2"><Button variant="outline" disabled={busy} onClick={() => step > 1 ? setStep(step - 1) : close()}>{step > 1 ? "Назад" : "Отмена"}</Button>{step < 4 ? <Button onClick={() => setStep(step + 1)}>Далее</Button> : <Button disabled={busy} onClick={() => { void save() }}>{busy ? "Сохранение…" : "Сохранить доступ"}</Button>}</div>
|
||
</DialogContent></Dialog>
|
||
}
|
||
function Connections({ api, operations }: { api: McpApi; operations: McpOperationDescriptor[] }) {
|
||
const query = useQuery(useCallback(signal => api.connections(signal), [api]))
|
||
const [editing, setEditing] = useState<McpConnection | "new" | null>(null), [token, setToken] = useState("")
|
||
const admin = can("mm:mcp:admin")
|
||
const refresh = query.refresh
|
||
const act = useCallback(async (work: () => Promise<unknown>) => { try { await work(); refresh() } catch (error) { toast.error(errorText(error)) } }, [refresh])
|
||
const columns = useMemo<ColumnDef<DataGridFeatures, RecordRow>[]>(() => [
|
||
{ accessorKey: "name", header: "Подключение", size: 200 },
|
||
{ id: "mode", header: "Режим", cell: ({ row }) => modeLabels[(row.original.policy as McpConnection["policy"]).mode] },
|
||
{ id: "status", header: "Состояние доступа", size: 260, cell: ({ row }) => <ConnectionAccessStatus connection={row.original as unknown as McpConnection} /> },
|
||
{ id: "expiresAt", header: "Доступ до", size: 170, cell: ({ row }) => date((row.original.policy as McpConnection["policy"]).expiresAt) },
|
||
{ accessorKey: "lastUsedAt", header: "Последний вызов", cell: ({ row }) => date(row.original.lastUsedAt) },
|
||
{ id: "ownerValidation", header: "Права владельца до", size: 190, cell: ({ row }) => { const connection = row.original as unknown as McpConnection; return <div className="flex flex-col gap-1"><span className="text-xs">{date(connection.ownerValidUntil)}</span>{connection.ownerId === getClaims()?.sub && <Button size="sm" variant="outline" disabled={!connection.generationValid || connection.policy.status === "revoked"} onClick={() => { void act(() => api.revalidate(connection.id)) }}>Обновить мои права</Button>}</div> } },
|
||
{ id: "actions", header: "Действия", size: 350, cell: ({ row }) => { const connection = row.original as unknown as McpConnection; return admin && <div className="flex flex-wrap gap-1"><Button size="sm" variant="outline" onClick={() => setEditing(connection)}>Разрешения</Button><Button size="sm" variant="outline" disabled={connection.policy.status === "revoked"} onClick={() => { void act(() => api.status(connection.id, connection.policy.status === "active" ? "paused" : "active")) }}>{connection.policy.status === "active" ? "Пауза" : "Включить"}</Button><Button size="sm" variant="outline" disabled={connection.policy.status === "revoked"} onClick={() => { if (window.confirm("Отозвать доступ подключения и все его токены?")) void act(() => api.status(connection.id, "revoked")) }}>Отозвать</Button><Button size="sm" variant="ghost" disabled={connection.policy.status === "revoked"} onClick={() => { if (window.confirm("Заменить токен? Старый токен перестанет работать.")) void act(async () => setToken((await api.rotate(connection.id)).token)) }}>Новый токен</Button></div> } },
|
||
], [api, admin, act])
|
||
return <><Panel title="Подключения" description="Токены, сроки и разрешения AI-клиентов" actions={admin && <Button onClick={() => setEditing("new")}><PlusIcon />Выдать доступ</Button>}><ErrorMessage message={query.error} /><Grid items={(query.data?.connections ?? []) as unknown as RecordRow[]} columns={columns} loading={!query.data && !query.error} /><p className="text-sm text-muted-foreground">HTTP endpoint: адрес backend + /mcp. Для stdio используйте MCP_URL и MCP_TOKEN с командой npm --prefix backend run mcp:stdio.</p></Panel>{editing && <ConnectionForm api={api} operations={operations} editing={editing === "new" ? undefined : editing} close={() => setEditing(null)} created={value => { setEditing(null); if (value) setToken(value); query.refresh() }} />}<TokenDialog token={token} close={() => setToken("")} /></>
|
||
}
|
||
function AccessTools({ api }: { api: McpApi }) {
|
||
const connections = useQuery(useCallback(signal => api.connections(signal), [api]))
|
||
const [selected, setSelected] = useState("")
|
||
const [grantBusy, setGrantBusy] = useState(false)
|
||
const [issued, setIssued] = useState<{ token: string; connectionName: string } | null>(null)
|
||
const grantLocked = useRef(false)
|
||
const connection = connections.data?.connections.find(value => value.id === selected)
|
||
|
||
const releaseGrant = () => {
|
||
grantLocked.current = false
|
||
setGrantBusy(false)
|
||
}
|
||
const issue = async (grant: Parameters<McpApi["grant"]>[1]) => {
|
||
if (!connection || grantLocked.current) return
|
||
grantLocked.current = true
|
||
setGrantBusy(true)
|
||
try {
|
||
const { token } = await api.grant(connection.id, grant)
|
||
setIssued({ token, connectionName: connection.name })
|
||
connections.refresh()
|
||
} catch (error) {
|
||
releaseGrant()
|
||
toast.error(errorText(error))
|
||
}
|
||
}
|
||
|
||
return <Panel title="Проверка подключения и временные допуски" description="Допуск с отдельным токеном может только сузить основной доступ и действует не более 24 часов">
|
||
<ErrorMessage message={connections.error} />
|
||
<NativeSelect label="Подключение для проверки" value={selected} onChange={setSelected}>
|
||
<option value="">Выберите подключение</option>
|
||
{connections.data?.connections.map(connection => <option key={connection.id} value={connection.id}>{connection.name}</option>)}
|
||
</NativeSelect>
|
||
{connection && <ConnectionAccessTools key={connection.id} api={api} connection={connection} issue={issue} grantBusy={grantBusy} />}
|
||
{issued && <TokenDialog token={issued.token} connectionName={issued.connectionName} close={() => { setIssued(null); releaseGrant() }} />}
|
||
</Panel>
|
||
}
|
||
|
||
function ConnectionAccessTools({ api, connection, issue, grantBusy }: {
|
||
api: McpApi
|
||
connection: McpConnection
|
||
issue: (grant: Parameters<McpApi["grant"]>[1]) => Promise<void>
|
||
grantBusy: boolean
|
||
}) {
|
||
const [result, setResult] = useState<Awaited<ReturnType<McpApi["check"]>> | null>(null)
|
||
const [operations, setOperations] = useState<string[]>([]), [serverText, setServerText] = useState(""), [minutes, setMinutes] = useState(60), [global, setGlobal] = useState(false)
|
||
const connectionId = connection.id
|
||
const grants = useQuery(useCallback(signal => api.grants(connectionId, signal), [api, connectionId]))
|
||
const checkRevision = useRef(0)
|
||
useEffect(() => () => { checkRevision.current++ }, [])
|
||
|
||
const check = async () => {
|
||
const revision = ++checkRevision.current
|
||
setResult(null)
|
||
try {
|
||
const readiness = await api.check(connectionId)
|
||
if (revision === checkRevision.current) setResult(readiness)
|
||
} catch (error) {
|
||
if (revision === checkRevision.current) toast.error(errorText(error))
|
||
}
|
||
}
|
||
|
||
const create = () => {
|
||
const now = new Date(), expires = new Date(Math.min(now.getTime() + minutes * 60000, Date.parse(connection.policy.expiresAt)))
|
||
try {
|
||
void issue({
|
||
id: crypto.randomUUID(), startsAt: now.toISOString(), expiresAt: expires.toISOString(),
|
||
scope: { serverIds: serverText.split(",").map(value => value.trim()).filter(Boolean).map(Number), operationIds: operations, allowGlobal: global },
|
||
})
|
||
} catch (error) {
|
||
toast.error(errorText(error))
|
||
}
|
||
}
|
||
|
||
return <>
|
||
<ErrorMessage message={grants.error} />
|
||
<p className="text-sm text-muted-foreground">Права владельца подтверждены до {date(connection.ownerValidUntil)}. Для продления войдите с актуальными правами портала; старые планы и допуски контроллера потребуется подготовить заново.</p>
|
||
<Button variant="outline" className="self-start" disabled={connection.ownerId !== getClaims()?.sub} onClick={() => { void api.revalidate(connectionId).then(() => { grants.refresh(); toast.success("Права владельца обновлены") }, error => toast.error(errorText(error))) }}>Подтвердить текущие права владельца</Button>
|
||
<Button variant="outline" className="self-start" onClick={() => { void check() }}>Проверить готовность доступа</Button>
|
||
{result !== null && <>
|
||
{result.protocolProbe?.capabilitiesRead && <p role="status" className="text-sm text-muted-foreground">
|
||
{({ passed: "Проверочное чтение возможностей прошло.", failed: "Проверочное чтение возможностей завершилось ошибкой.", skipped: "Проверочное чтение возможностей пропущено." })[result.protocolProbe.capabilitiesRead]}
|
||
</p>}
|
||
<pre className="overflow-auto rounded bg-muted p-3 text-xs">{pretty(result)}</pre>
|
||
</>}
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
<Field label={`Серверы допуска (доступны: ${connection.policy.scope.serverIds.join(", ")})`}><Input value={serverText} onChange={event => setServerText(event.target.value)} placeholder="ID через запятую" /></Field>
|
||
<Field label="Срок допуска, минут"><Input type="number" min={1} max={1440} value={minutes} onChange={event => setMinutes(Number(event.target.value))} /></Field>
|
||
</div>
|
||
{connection.policy.scope.allowGlobal && <Choice label="Разрешить глобальные действия в допуске" checked={global} onChange={setGlobal} />}
|
||
<div className="grid max-h-60 gap-2 overflow-y-auto sm:grid-cols-2">{connection.policy.scope.operationIds.map(id => <Choice key={id} label={id} checked={operations.includes(id)} onChange={checked => setOperations(values => checked ? [...values, id] : values.filter(value => value !== id))} />)}</div>
|
||
<Button className="self-start" disabled={grantBusy || !operations.length || connection.policy.status !== "active"} onClick={create}>Выдать временный допуск</Button>
|
||
{grants.data?.grants.map(grant => <div key={grant.id} className="flex flex-wrap items-center justify-between gap-2 rounded border p-3 text-sm">
|
||
<span>До {date(grant.grant.expiresAt)} · {grant.revoked_at ? "отозван" : "выдан"}</span>
|
||
<Button size="sm" variant="outline" disabled={!!grant.revoked_at} onClick={() => { void api.revokeGrant(grant.id).then(grants.refresh, error => toast.error(errorText(error))) }}>Отозвать допуск</Button>
|
||
</div>)}
|
||
</>
|
||
}
|
||
function RoutingProfileForm({ api, editing, close }: { api: McpApi; editing?: RecordRow; close: () => void }) {
|
||
const connections = useQuery(useCallback(signal => api.connections(signal), [api]))
|
||
const original = editing ? routeAiProfileSchema.parse(editing.config) : null
|
||
const [connectionId, setConnectionId] = useState(String(editing?.connection_id ?? "")), [name, setName] = useState(original?.name ?? ""), [serverId, setServerId] = useState(String(original?.serverId ?? ""))
|
||
const [community, setCommunity] = useState(original?.community ?? ""), [family, setFamily] = useState(original?.family ?? "ipv4"), [table, setTable] = useState(original?.table ?? "main")
|
||
const [probeVrf, setProbeVrf] = useState(original?.probeVrf ?? "main")
|
||
const [bindBgp, setBindBgp] = useState(!!original?.bgpContext)
|
||
const [bgp, setBgp] = useState(original?.bgpContext ?? { connectionId: "", instance: "", inputChain: "", vrf: "main" })
|
||
const [enabled, setEnabled] = useState(editing?.enabled === true), [expires, setExpires] = useState(() => new Date(Date.now() + 1800000).toISOString().slice(0, 16))
|
||
const [autoRecovery, setAutoRecovery] = useState(original?.autoRecovery ?? false)
|
||
const [weights, setWeights] = useState(original?.weights ?? { ping: 60, speed: 30, loss: 10 }), [threshold, setThreshold] = useState(original?.switchThreshold ?? 15), [cooldown, setCooldown] = useState(original?.cooldownSec ?? 600)
|
||
const [pin, setPin] = useState(original?.pinnedCandidateId ?? ""), [excluded, setExcluded] = useState<string[]>(original?.excludedCandidateIds ?? [])
|
||
const [sla, setSla] = useState({ ping: original?.maxPingMs?.toString() ?? "", loss: original?.maxLossPct?.toString() ?? "", speed: original?.minBandwidthMbps?.toString() ?? "" })
|
||
const [candidates, setCandidates] = useState<CandidateForm[]>(() => original?.candidates.map(candidate => ({ ...candidate, jumpHostId: String(candidate.jumpHostId), exitNodeId: String(candidate.exitNodeId) })) ?? [{ id: "path-1", wanInterface: "", jumpHostId: "", exitNodeId: "", downstreamInterface: "", gateway: "", gatewayRef: { resource: "gre" as "gre" | "ipv4_route" | "ipv6_route", id: "" }, wanRemoteInterface: "", downstreamRemoteInterface: "" }])
|
||
const controllerReason = routeAiControllerBlockReason({ table, probeVrf, bgpContext: bindBgp ? bgp : undefined, autoRecovery, candidates })
|
||
const [error, setError] = useState(""), [busy, setBusy] = useState(false), [plan, setPlan] = useState("")
|
||
const save = async () => {
|
||
setBusy(true); setError("")
|
||
try {
|
||
const profile = routeAiProfileSchema.parse({ ...original, name, serverId: Number(serverId), community, family, table, probeVrf, bgpContext: bindBgp ? bgp : undefined, weights, switchThreshold: threshold, cooldownSec: cooldown, autoRecovery, pinnedCandidateId: pin || null, excludedCandidateIds: excluded,
|
||
maxPingMs: sla.ping ? Number(sla.ping) : null, maxLossPct: sla.loss ? Number(sla.loss) : null, minBandwidthMbps: sla.speed ? Number(sla.speed) : null,
|
||
candidates: candidates.map(candidate => ({ ...candidate, jumpHostId: Number(candidate.jumpHostId), exitNodeId: Number(candidate.exitNodeId), wanRemoteInterface: candidate.wanRemoteInterface || undefined, downstreamRemoteInterface: candidate.downstreamRemoteInterface || undefined })) })
|
||
if (enabled && controllerReason) throw new Error(controllerReason + " Отключите контроллер для сохранения профиля.")
|
||
const result = await api.profilePlan({ connectionId, ...(editing ? { profileId: editing.id } : {}), profile, enabled, expiresAt: new Date(expires + "Z").toISOString() })
|
||
setPlan(result.planId)
|
||
} catch (failure) { setError(errorText(failure)) } finally { setBusy(false) }
|
||
}
|
||
return <Dialog open onOpenChange={open => { if (!open && !busy) close() }}><DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-4xl"><DialogHeader><DialogTitle>{editing ? "Изменить профиль Route AI" : "Новый профиль Route AI"}</DialogTitle><DialogDescription>Проверьте пути и допуск. Сохранение создаёт план для отдельного подтверждения и применения.</DialogDescription></DialogHeader><ErrorMessage message={error || connections.error} />{plan ? <><p>Создан план {plan}. Откройте «Планы и операции», проверьте изменения, подтвердите и примените план.</p><Button onClick={close}>Закрыть</Button></> : <><div className="grid gap-3 sm:grid-cols-2"><NativeSelect label="Подключение контроллера" value={connectionId} onChange={setConnectionId}><option value="">Выберите подключение владельца</option>{connections.data?.connections.filter(connection => connection.ownerId === getClaims()?.sub && connection.policy.status === "active" && connection.policy.mode !== "observer" && connection.policy.scope.operationIds.includes("routing.profiles.save")).map(connection => <option key={connection.id} value={connection.id}>{connection.name}</option>)}</NativeSelect><Field label="Название профиля"><Input value={name} onChange={event => setName(event.target.value)} /></Field><Field label="ID домашнего сервера"><Input type="number" min={1} value={serverId} onChange={event => setServerId(event.target.value)} /></Field><Field label="Community"><Input value={community} onChange={event => setCommunity(event.target.value)} placeholder="65000:10" /></Field><NativeSelect label="Семейство адресов" value={family} onChange={value => setFamily(value as typeof family)}><option value="ipv4">IPv4</option><option value="ipv6">IPv6</option></NativeSelect><Field label="Таблица маршрутизации"><Input value={table} onChange={event => setTable(event.target.value)} /></Field><Field label="VRF диагностических проб"><Input value={probeVrf} onChange={event => setProbeVrf(event.target.value)} /></Field></div>
|
||
<p className="text-sm text-muted-foreground">Таблица определяет установку маршрутов. VRF диагностических проб определяет контекст измерений и задаётся отдельно от транспортной VRF BGP.</p>
|
||
<Choice label="Привязать профиль к конкретному BGP-подключению RouterOS" checked={bindBgp} onChange={setBindBgp} />
|
||
{bindBgp ? <fieldset className="rounded-lg border p-3"><legend className="px-1 text-sm font-medium">Контекст BGP</legend><div className="grid gap-3 sm:grid-cols-2">{([{ key: "connectionId", title: "ID BGP-подключения RouterOS" }, { key: "instance", title: "Экземпляр BGP" }, { key: "inputChain", title: "Входящая цепочка BGP" }, { key: "vrf", title: "Транспортная VRF BGP" }] as const).map(field => <Field key={field.key} label={field.title}><Input value={bgp[field.key]} placeholder={field.key === "connectionId" ? "*A" : undefined} onChange={event => setBgp(values => ({ ...values, [field.key]: event.target.value }))} /></Field>)}</div><p className="mt-2 text-sm text-muted-foreground">Заполните все поля. Снятие привязки явно удалит контекст BGP из плана профиля.</p></fieldset> : <p className="text-sm text-muted-foreground">Подключение BGP будет определено по наблюдаемой конфигурации; неоднозначная привязка блокирует переключение.</p>}
|
||
{controllerReason && <p role="status" className="text-sm text-muted-foreground">{controllerReason} Профиль можно сохранить с выключенным контроллером для анализа и подготовки предложений.</p>}
|
||
{candidates.map((candidate, index) => <fieldset key={candidate.id} className="rounded-lg border p-3"><legend className="px-1 text-sm font-medium">Путь {index + 1} · {candidate.id}</legend><div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">{([{ key: "wanInterface", title: "WAN-интерфейс дома" }, { key: "jumpHostId", title: "ID JumpHost" }, { key: "exitNodeId", title: "ID ExitNode" }, { key: "downstreamInterface", title: "Интерфейс JumpHost к ExitNode" }, { key: "gateway", title: "Адрес gateway" }, { key: "wanRemoteInterface", title: "Входящий интерфейс JumpHost" }, { key: "downstreamRemoteInterface", title: "Входящий интерфейс ExitNode" }] as const).map(field => <Field key={field.key} label={field.title}><Input value={candidate[field.key] ?? ""} onChange={event => setCandidates(values => values.map(value => value.id === candidate.id ? { ...value, [field.key]: event.target.value } : value))} /></Field>)}<NativeSelect label="Тип шлюза" value={candidate.gatewayRef.resource} onChange={resource => setCandidates(values => values.map(value => value.id === candidate.id ? { ...value, gatewayRef: { ...value.gatewayRef, resource: resource as typeof value.gatewayRef.resource } } : value))}><option value="gre">GRE</option><option value="ipv4_route">Рекурсивный IPv4</option><option value="ipv6_route">Рекурсивный IPv6</option></NativeSelect><Field label="ID шлюза на устройстве"><Input value={candidate.gatewayRef.id} onChange={event => setCandidates(values => values.map(value => value.id === candidate.id ? { ...value, gatewayRef: { ...value.gatewayRef, id: event.target.value } } : value))} /></Field></div><div className="mt-3 flex flex-wrap items-center gap-3"><Choice label="Исключить путь" checked={excluded.includes(candidate.id)} onChange={checked => setExcluded(values => checked ? [...values, candidate.id] : values.filter(value => value !== candidate.id))} /><Button size="sm" variant="ghost" disabled={candidates.length === 1} onClick={() => { setCandidates(values => values.filter(value => value.id !== candidate.id)); setExcluded(values => values.filter(value => value !== candidate.id)); if (pin === candidate.id) setPin("") }}>Удалить путь</Button></div></fieldset>)}
|
||
<CandidateProbeEditor candidates={candidates} update={(id, controlProbe) => setCandidates(values => values.map(candidate => candidate.id === id ? { ...candidate, controlProbe } : candidate))} /><Button variant="outline" className="self-start" disabled={candidates.length >= 100} onClick={() => setCandidates(values => [...values, { ...values[0], id: crypto.randomUUID(), gateway: "", gatewayRef: { ...values[0].gatewayRef, id: "" } }])}>Добавить путь</Button><div className="grid gap-3 sm:grid-cols-3">{Object.entries(weights).map(([key, value]) => <Field key={key} label={`Вес ${key}, %`}><Input type="number" min={0} max={100} value={value} onChange={event => setWeights(values => ({ ...values, [key]: Number(event.target.value) }))} /></Field>)}{([{ key: "ping", title: "Максимум RTT, мс" }, { key: "loss", title: "Максимум потерь, %" }, { key: "speed", title: "Минимум скорости, Mbps" }] as const).map(field => <Field key={field.key} label={field.title}><Input type="number" min={0} value={sla[field.key]} placeholder="Без ограничения" onChange={event => setSla(values => ({ ...values, [field.key]: event.target.value }))} /></Field>)}<Field label="Порог улучшения, баллы"><Input type="number" min={0} max={100} value={threshold} onChange={event => setThreshold(Number(event.target.value))} /></Field><Field label="Пауза между переключениями, сек"><Input type="number" min={60} value={cooldown} onChange={event => setCooldown(Number(event.target.value))} /></Field><NativeSelect label="Закреплённый путь" value={pin} onChange={setPin}><option value="">Автоматический выбор</option>{candidates.map((candidate, index) => <option key={candidate.id} value={candidate.id}>Путь {index + 1}</option>)}</NativeSelect></div><Choice label="Разрешить заранее зарезервированное восстановление" checked={autoRecovery} onChange={setAutoRecovery} /><Choice label="Разрешить работу контроллера после применения плана" checked={enabled} onChange={setEnabled} /><Field label="Допуск действует до (UTC, максимум 24 часа)"><Input type="datetime-local" value={expires} onChange={event => setExpires(event.target.value)} /></Field><Button disabled={busy || !connectionId || (!!controllerReason && enabled)} onClick={() => { void save() }}>{busy ? "Подготовка…" : "Подготовить план профиля"}</Button></>}</DialogContent></Dialog>
|
||
}
|
||
type CandidateForm = Omit<RouteAiProfile["candidates"][number], "jumpHostId" | "exitNodeId"> & { jumpHostId: string; exitNodeId: string }
|
||
function CandidateProbeEditor({ candidates, update }: { candidates: CandidateForm[]; update: (id: string, probe: CandidateForm["controlProbe"]) => void }) {
|
||
return <div className="grid gap-3">{candidates.map((candidate, index) => <fieldset key={candidate.id} className="flex flex-col gap-3 rounded-md border p-3"><legend className="px-1 text-sm">Контрольная проверка пути {index + 1}</legend><Choice label={`Включить контроль пути ${index + 1}`} checked={!!candidate.controlProbe} onChange={checked => update(candidate.id, checked ? { targetInterface: candidate.downstreamRemoteInterface ?? "", expectedHops: [{ serverId: Number(candidate.jumpHostId), interface: candidate.wanRemoteInterface ?? "" }, { serverId: Number(candidate.exitNodeId), interface: candidate.downstreamRemoteInterface ?? "" }] } : undefined)} />{candidate.controlProbe && <><Field label={`Контрольный интерфейс ExitNode пути ${index + 1}`}><Input value={candidate.controlProbe.targetInterface} onChange={event => update(candidate.id, { ...candidate.controlProbe!, targetInterface: event.target.value })} /></Field><Field label={`Исходный IP контрольной пробы пути ${index + 1}`}><Input value={candidate.controlProbe.sourceAddress ?? ""} placeholder="Автоматический выбор RouterOS" onChange={event => update(candidate.id, { ...candidate.controlProbe!, sourceAddress: event.target.value || undefined })} /></Field><Field label={`Конкретный IP ExitNode пути ${index + 1}`}><Input value={candidate.controlProbe.targetAddress ?? ""} placeholder="Для интерфейса с несколькими адресами" onChange={event => update(candidate.id, { ...candidate.controlProbe!, targetAddress: event.target.value || undefined })} /></Field>{candidate.controlProbe.expectedHops.map((hop, hopIndex) => <div key={hopIndex} className="grid gap-3 sm:grid-cols-2"><Field label={`Сервер hop ${hopIndex + 1} пути ${index + 1}`}><Input type="number" min={1} value={hop.serverId || ""} onChange={event => update(candidate.id, { ...candidate.controlProbe!, expectedHops: candidate.controlProbe!.expectedHops.map((item, current) => current === hopIndex ? { ...item, serverId: Number(event.target.value) } : item) })} /></Field><Field label={`IP hop ${hopIndex + 1} пути ${index + 1}`}><Input value={hop.address ?? ""} placeholder="Для интерфейса с несколькими адресами" onChange={event => update(candidate.id, { ...candidate.controlProbe!, expectedHops: candidate.controlProbe!.expectedHops.map((item, current) => current === hopIndex ? { ...item, address: event.target.value || undefined } : item) })} /></Field><Field label={`Интерфейс hop ${hopIndex + 1} пути ${index + 1}`}><Input value={hop.interface} onChange={event => update(candidate.id, { ...candidate.controlProbe!, expectedHops: candidate.controlProbe!.expectedHops.map((item, current) => current === hopIndex ? { ...item, interface: event.target.value } : item) })} /></Field></div>)}<p className="text-xs text-muted-foreground">Проверяются зарегистрированные адреса, порядок hops и достижение ExitNode. Это наблюдение контрольных пакетов; оно не доказывает путь всего пользовательского трафика.</p></>}</fieldset>)}</div>
|
||
}
|
||
function RoutePathVerification({ api }: { api: McpApi }) {
|
||
const profiles = useQuery(useCallback(signal => api.profiles(signal), [api]))
|
||
const [profileId, setProfileId] = useState(""), [candidateId, setCandidateId] = useState(""), [busy, setBusy] = useState(false)
|
||
const [proofPurpose, setProofPurpose] = useState<"community" | "control-path">("community")
|
||
const [feedback, setFeedback] = useState<{ key: string; result: unknown } | { key: string; error: string } | null>(null)
|
||
const revision = useRef(0)
|
||
const profile = profiles.data?.profiles.find(profile => profile.id === profileId), config = routeAiProfileSchema.safeParse(profile?.config)
|
||
const candidates = config.success ? config.data.candidates.filter(candidate => candidate.controlProbe) : []
|
||
const canCheck = candidates.some(candidate => candidate.id === candidateId)
|
||
const selectionKey = JSON.stringify([profileId, profile?.version, candidateId, proofPurpose])
|
||
const currentFeedback = feedback?.key === selectionKey ? feedback : null
|
||
const invalidate = () => { revision.current++; setFeedback(null) }
|
||
const check = async () => {
|
||
if (busy || !canCheck) return
|
||
const request = ++revision.current
|
||
setBusy(true); setFeedback(null)
|
||
try {
|
||
const result = await api.checkRoutePath(profileId, candidateId, proofPurpose)
|
||
if (request === revision.current) setFeedback({ key: selectionKey, result })
|
||
} catch (failure) {
|
||
if (request === revision.current) setFeedback({ key: selectionKey, error: errorText(failure) })
|
||
} finally { setBusy(false) }
|
||
}
|
||
return <Panel title="Контрольная проверка пути" description="Ограниченные ping и traceroute; неизвестный результат не считается отказом пути.">
|
||
<ErrorMessage message={profiles.error || (currentFeedback && "error" in currentFeedback ? currentFeedback.error : "")} />
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
<NativeSelect label="Профиль для проверки пути" value={profileId} onChange={value => { invalidate(); setProfileId(value); setCandidateId("") }}><option value="">Выберите профиль</option>{profiles.data?.profiles.map(profile => <option key={String(profile.id)} value={String(profile.id)}>{String((profile.config as RecordRow)?.name ?? profile.id)}</option>)}</NativeSelect>
|
||
<NativeSelect label="Проверяемый путь" value={candidateId} onChange={value => { invalidate(); setCandidateId(value) }}><option value="">Выберите путь</option>{candidates.map(candidate => <option key={candidate.id} value={candidate.id}>{candidate.id}</option>)}</NativeSelect>
|
||
<NativeSelect label="Назначение проверки" value={proofPurpose} onChange={value => { invalidate(); setProofPurpose(value as typeof proofPurpose) }}><option value="community">Путь Community</option><option value="control-path">Самостоятельный контрольный путь</option></NativeSelect>
|
||
</div>
|
||
<p className="text-sm text-muted-foreground">{proofPurpose === "control-path" ? "Проверяется только контрольный путь. Результат не разрешает переключение Community и не подтверждает обратный путь." : "Проверка учитывает Community, таблицу маршрутизации и BGP. Применение изменений требует отдельного плана; обратный путь проверяется отдельно."}</p>
|
||
<Button disabled={busy || !canCheck} onClick={() => { void check() }}>Проверить контрольный путь</Button>
|
||
{currentFeedback && "result" in currentFeedback && <pre role="status" className="max-h-72 overflow-auto whitespace-pre-wrap break-all text-xs">{pretty(currentFeedback.result)}</pre>}
|
||
</Panel>
|
||
}
|
||
function RoutingProfiles({ api }: { api: McpApi }) {
|
||
const query = useQuery(useCallback(signal => api.profiles(signal), [api])), [selected, setSelected] = useState<RecordRow | null>(null)
|
||
const [editing, setEditing] = useState<RecordRow | "new" | null>(null)
|
||
const columns = useMemo<ColumnDef<DataGridFeatures, RecordRow>[]>(() => [
|
||
{ id: "name", header: "Профиль", size: 220, cell: ({ row }) => String((row.original.config as RecordRow)?.name ?? row.original.id) },
|
||
{ id: "community", header: "Community / семейство", cell: ({ row }) => { const config = row.original.config as RecordRow; return `${config?.community} · ${config?.family} · ${config?.table}` } },
|
||
{ id: "state", header: "Контроллер", cell: ({ row }) => <Badge variant={row.original.enabled ? "success-light" : "warning-light"}>{row.original.enabled ? "включён" : "пауза"}</Badge> },
|
||
{ id: "reason", header: "Решение", size: 300, cell: ({ row }) => String((row.original.state as RecordRow)?.pausedReason ?? (row.original.state as RecordRow)?.reason ?? "Ожидает проверки") },
|
||
{ accessorKey: "grant_expires_at", header: "Допуск до", cell: ({ row }) => date(row.original.grant_expires_at) },
|
||
{ id: "actions", header: "", cell: ({ row }) => can("mm:mcp:admin") && <Button size="sm" variant="outline" disabled={!row.original.enabled} onClick={() => { void api.pauseProfile(String(row.original.id)).then(query.refresh, error => toast.error(errorText(error))) }}>Остановить</Button> },
|
||
], [api, query.refresh])
|
||
return <><Panel title="Постоянный контроллер Route AI" description="Профили, временные допуски и решения маршрутизации" actions={can("mm:mcp:admin") && can("mm:network:write") && <Button onClick={() => setEditing("new")}>Создать профиль</Button>}><ErrorMessage message={query.error} /><Grid items={query.data?.profiles ?? []} columns={columns} loading={!query.data && !query.error} onSelect={setSelected} /><p className="text-sm text-muted-foreground">Контроллер учитывает свежесть измерений, закрепления, SLA, гистерезис, cooldown и бюджет переключений. Неизвестный фактический путь не считается подтверждённым.</p><Dialog open={!!selected} onOpenChange={open => { if (!open) setSelected(null) }}><DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-3xl"><DialogHeader><DialogTitle>Профиль Route AI</DialogTitle><DialogDescription>Сохранённые параметры и последнее решение контроллера</DialogDescription></DialogHeader><pre className="overflow-auto whitespace-pre-wrap break-all text-xs">{pretty(selected)}</pre>{can("mm:mcp:admin") && can("mm:network:write") && <Button onClick={() => { setEditing(selected); setSelected(null) }}>Изменить профиль</Button>}</DialogContent></Dialog></Panel>{editing && <RoutingProfileForm api={api} editing={editing === "new" ? undefined : editing} close={() => { setEditing(null); query.refresh() }} />}</>
|
||
}
|
||
function Overview({ api }: { api: McpApi }) {
|
||
const query = useQuery(useCallback(signal => api.overview(signal), [api]))
|
||
return <Panel title="Управление AI-доступом" description={query.data ? "Последнее обновление: " + date(query.data.observedAt) : "Загрузка состояния"} actions={can("mm:mcp:pause") && <Button variant="outline" onClick={() => { if (window.confirm("Остановить новые обращения MCP и все профили Route AI? Начатые записи будут завершены или сверены.")) void api.stopAutomation().then(query.refresh, error => toast.error(errorText(error))) }}><StopCircleIcon />Остановить автоматические изменения</Button>}><ErrorMessage message={query.error} /><div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">{Object.entries({ connections: "Подключения", calls: "Вызовы за 24 часа", errors: "Ошибки и отказы", approvals: "Ожидают подтверждения", running: "Выполняются" }).map(([key, title]) => <div key={key} className="rounded-lg border p-4"><p className="text-sm text-muted-foreground">{title}</p><p className="mt-2 text-2xl font-semibold tabular-nums">{query.data ? query.data[key as "calls"] : "—"}</p></div>)}</div><Badge variant={query.data?.enabled ? "success-light" : "warning-light"}>{query.data?.enabled ? "MCP включён" : "MCP выключен"}</Badge><p className="text-sm text-muted-foreground">Выключатель прекращает новые вызовы. Выключатель также останавливает новые решения Route AI. Отдельный профиль можно остановить на вкладке Route AI.</p><PollStorageCapacity capacity={query.error ? {status:"unavailable"} : query.data?.pollStorageCapacity} loading={!query.data && !query.error} refresh={query.refresh} /></Panel>
|
||
}
|
||
const recordQueryFilters = {
|
||
requests: ["tool", "status", "connectionId", "serverId", "userId", "from", "to"],
|
||
plans: ["status", "connectionId", "from", "to"],
|
||
operations: ["status", "connectionId", "from", "to"],
|
||
audit: ["connectionId", "userId", "from", "to"],
|
||
drift: ["from", "to"],
|
||
} as const
|
||
|
||
function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFilters }) {
|
||
const urlParams = useSearchParams(), [selected, setSelected] = useState<RecordRow | null>(null)
|
||
const [startedOperation, setStartedOperation] = useState<string | null>(null)
|
||
const operationId = kind === "operations" ? urlParams.get("operation") : null
|
||
const operationLink = new URLSearchParams(urlParams)
|
||
if (startedOperation) operationLink.set("operation", startedOperation)
|
||
const fields: readonly string[] = recordQueryFilters[kind]
|
||
const filterValue = (field: string) => urlParams.get(kind + "." + field) ?? ""
|
||
const cursor = filterValue("cursor")
|
||
const setFilter = (key: string, value: string) => { const url = new URL(window.location.href); if (value) url.searchParams.set(kind + "." + key, value); else url.searchParams.delete(kind + "." + key); if (key !== "cursor") url.searchParams.delete(kind + ".cursor"); window.history.replaceState(null, "", url.pathname + url.search) }
|
||
const queryFields = new URLSearchParams({ limit: "50" })
|
||
for (const field of [...fields, "cursor"]) {
|
||
const value = filterValue(field)
|
||
if (value) queryFields.set(field, value)
|
||
}
|
||
const queryKey = queryFields.toString()
|
||
const load = useCallback((signal: AbortSignal) => api.records(kind, new URLSearchParams(queryKey), signal), [api, kind, queryKey])
|
||
const query = useQuery(load, `${kind}?${queryKey}`)
|
||
const journalConnection = kind === "requests" ? filterValue("connectionId") : ""
|
||
const navigationClass = buttonVariants({ variant: "outline", className: "self-start" })
|
||
const selectRecord = useCallback((row: RecordRow) => {
|
||
if (kind !== "operations") { setSelected(row); return }
|
||
const url = new URL(window.location.href)
|
||
url.searchParams.set("operation", String(row.id))
|
||
window.history.pushState(null, "", url.pathname + url.search)
|
||
}, [kind])
|
||
const columns = useMemo<ColumnDef<DataGridFeatures, RecordRow>[]>(() => [
|
||
{ accessorKey: "created_at", header: "Время", size: 170, cell: ({ row }) => date(row.original.created_at ?? row.original.createdAt ?? row.original.detected_at) },
|
||
{ id: "action", header: "Действие", size: 260, cell: ({ row }) => String(row.original.tool ?? row.original.operationId ?? row.original.action ?? row.original.id) },
|
||
{ id: "state", header: "Результат", cell: ({ row }) => <Badge variant={["completed", "applied"].includes(String(row.original.status ?? row.original.state)) ? "success-light" : "info-light"}>{String(row.original.status ?? row.original.state ?? "событие")}</Badge> },
|
||
{ accessorKey: "connection_id", header: "Подключение", size: 250, cell: ({ row }) => <span className="font-mono text-xs">{String(row.original.connection_id ?? row.original.connectionId ?? "—")}</span> },
|
||
{ accessorKey: "error_code", header: "Причина", cell: ({ row }) => String(row.original.error_code ?? row.original.verification ?? "—") },
|
||
{ id: "details", header: "", cell: ({ row }) => <Button size="sm" variant="ghost" onClick={() => selectRecord(row.original)}>Открыть</Button> },
|
||
], [selectRecord])
|
||
const pendingPlanRequests = useRef(new Set<string>())
|
||
const [pendingPlans, setPendingPlans] = useState<ReadonlySet<string>>(() => new Set())
|
||
const selectedPlanPending = kind === "plans" && !!selected && pendingPlans.has(String(selected.id))
|
||
const planExpiry = kind === "plans" && typeof selected?.expiresAt === "string" ? Date.parse(selected.expiresAt) : null
|
||
const [planClock, setPlanClock] = useState(0)
|
||
useEffect(() => {
|
||
if (planExpiry === null || !Number.isFinite(planExpiry)) return
|
||
let timer: ReturnType<typeof setTimeout>
|
||
const update = () => {
|
||
clearTimeout(timer)
|
||
const now = Date.now()
|
||
setPlanClock(now)
|
||
if (now < planExpiry) timer = setTimeout(update, Math.min(planExpiry - now, 2_147_483_647))
|
||
}
|
||
update()
|
||
document.addEventListener("visibilitychange", update)
|
||
return () => {
|
||
clearTimeout(timer)
|
||
document.removeEventListener("visibilitychange", update)
|
||
}
|
||
}, [planExpiry])
|
||
const planExpired = planExpiry !== null && (!Number.isFinite(planExpiry) || planExpiry <= planClock)
|
||
const currentPlan = kind === "plans" && selected ? query.data?.items.find(item => item.id === selected.id) : null
|
||
const planChanged = kind === "plans" && !!selected && (!currentPlan || query.error || pretty(currentPlan) !== pretty(selected))
|
||
const planActionDisabled = selectedPlanPending || planExpired || !!planChanged
|
||
const submitPlan = async (action: "approve" | "reject" | "apply") => {
|
||
if (!selected || planChanged) return
|
||
if (planExpiry !== null && (!Number.isFinite(planExpiry) || planExpiry <= Date.now())) {
|
||
setPlanClock(Date.now())
|
||
return
|
||
}
|
||
const plan = selected
|
||
const planId = String(plan.id)
|
||
const pending = pendingPlanRequests.current
|
||
if (pending.has(planId)) return
|
||
pending.add(planId)
|
||
setPendingPlans(new Set(pending))
|
||
try {
|
||
if (action === "apply") {
|
||
const operation = await api.apply(planId)
|
||
setStartedOperation(operation.id)
|
||
toast.success("Запрос принят. Откройте выполнение операции для проверки результата.")
|
||
} else {
|
||
await api.decision(planId, action === "approve")
|
||
}
|
||
setSelected(current => current === plan ? null : current)
|
||
query.refresh()
|
||
} catch (error) {
|
||
toast.error(errorText(error))
|
||
} finally {
|
||
pending.delete(planId)
|
||
setPendingPlans(new Set(pending))
|
||
}
|
||
}
|
||
const closeDetails = () => {
|
||
setSelected(null)
|
||
if (operationId) {
|
||
const url = new URL(window.location.href)
|
||
url.searchParams.delete("operation")
|
||
window.history.replaceState(null, "", url.pathname + url.search)
|
||
}
|
||
}
|
||
return (
|
||
<Panel title={kind === "requests" ? "Обращения" : kind === "plans" ? "Планы изменений" : kind === "operations" ? "Выполнение операций" : kind === "drift" ? "Расхождения конфигурации" : "Административный аудит"} description="Чтение, отказы и изменения доступны для расследования">
|
||
<ErrorMessage message={query.error} />
|
||
{startedOperation && <Link className={navigationClass} href={`/mcp?${operationLink}`}>Открыть выполнение операции</Link>}
|
||
{journalConnection && <Link className={navigationClass} href={`/mcp?${new URLSearchParams({ tab: "plans", "plans.connectionId": journalConnection, "operations.connectionId": journalConnection })}`}>Планы и операции подключения</Link>}
|
||
{journalConnection && can("mm:mcp:admin") && <Link className={navigationClass} href={`/mcp?${new URLSearchParams({ tab: "settings", "audit.connectionId": journalConnection })}#mcp-audit`}>Административный аудит подключения</Link>}
|
||
<p className="text-xs text-muted-foreground">Последнее успешное обновление: {date(query.updatedAt)}{query.error && query.data ? " · показаны ранее полученные данные" : ""}</p>
|
||
<RecordFilters fields={fields} value={filterValue} update={setFilter} />
|
||
<div className="flex flex-wrap gap-2">
|
||
{fields.includes("tool") && <Input className="max-w-sm" aria-label="Имя инструмента" placeholder="Имя инструмента" value={filterValue("tool")} onChange={event => setFilter("tool", event.target.value)} />}
|
||
{fields.includes("status") && <Input className="max-w-xs" aria-label="Фильтр состояния" placeholder="Состояние" value={filterValue("status")} onChange={event => setFilter("status", event.target.value)} />}
|
||
<Button variant="outline" onClick={query.refresh}><RefreshCwIcon />Обновить</Button>
|
||
</div>
|
||
<Grid items={query.data?.items ?? []} columns={columns} total={query.data?.total} loading={!query.data && !query.error} onSelect={selectRecord} />
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm text-muted-foreground">Найдено: {query.data?.total ?? "—"}</span>
|
||
<div className="flex gap-2">
|
||
<Button variant="outline" disabled={!cursor} onClick={() => setFilter("cursor", "")}>В начало</Button>
|
||
<Button variant="outline" disabled={!query.data?.nextCursor} onClick={() => setFilter("cursor", query.data?.nextCursor ?? "")}>Далее</Button>
|
||
</div>
|
||
</div>
|
||
<Dialog open={!!selected || !!operationId} onOpenChange={open => { if (!open) closeDetails() }}>
|
||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-3xl">
|
||
<DialogHeader>
|
||
<DialogTitle>Детали {kind === "plans" ? "плана" : kind === "operations" ? "операции" : "обращения"}</DialogTitle>
|
||
<DialogDescription>{kind === "plans" ? "Подтверждение относится к показанному плану." : "Данные журнала с маскировкой секретных полей."}</DialogDescription>
|
||
</DialogHeader>
|
||
{operationId ? <OperationMonitor key={operationId} api={api} id={operationId} /> : kind === "plans" && selected ? <PlanReview plan={selected} /> : <pre className="max-h-[55dvh] overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-3 text-xs">{pretty(selected)}</pre>}
|
||
{planExpired && <p role="status" className="text-sm text-muted-foreground">Срок плана истёк. Подготовьте новый план.</p>}
|
||
{planChanged && !planExpired && <p role="status" className="text-sm text-muted-foreground">Текущая версия плана недоступна или изменилась. Откройте план заново.</p>}
|
||
{kind === "plans" && ["approved", "draft"].includes(String(selected?.state)) && can("mm:mcp:admin") && <Button disabled={planActionDisabled} onClick={() => { void submitPlan("apply") }}>Применить план</Button>}
|
||
{kind === "plans" && selected?.state === "awaiting-approval" && can("mm:mcp:approve") && <div className="flex gap-2"><Button disabled={planActionDisabled} onClick={() => { void submitPlan("approve") }}>Подтвердить план</Button><Button variant="outline" disabled={planActionDisabled} onClick={() => { void submitPlan("reject") }}>Отклонить</Button></div>}
|
||
</DialogContent>
|
||
</Dialog>
|
||
</Panel>
|
||
)
|
||
}
|
||
|
||
const operationStates: Record<string, string> = {
|
||
pending: "Ожидает выполнения", running: "Выполняется", applied: "Применена", failed: "Ошибка выполнения",
|
||
blocked: "Заблокирована", cancelled: "Отменена", partial: "Выполнена частично", unknown: "Исход не подтверждён",
|
||
}
|
||
const stepStates: Record<string, string> = { intent: "Записано намерение", applied: "Применён", acknowledged: "Получено подтверждение приёма", verified: "Результат проверен" }
|
||
|
||
function OperationMonitor({ api, id }: { api: McpApi; id: string }) {
|
||
const recoveryLink = new URLSearchParams(useSearchParams())
|
||
recoveryLink.delete("operation")
|
||
recoveryLink.set("reconcile", id)
|
||
const load = useCallback((signal: AbortSignal) => api.records("operations", new URLSearchParams({ operationId: id, limit: "1" }), signal), [api, id])
|
||
const query = useQuery(load, id)
|
||
const operation = query.data?.items.find(item => item.id === id)
|
||
const state = String(operation?.state ?? "")
|
||
const steps = Array.isArray(operation?.steps) ? operation.steps as RecordRow[] : []
|
||
return <>
|
||
<ErrorMessage message={query.error} />
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="break-all font-mono text-xs">{id}</span>
|
||
<Button variant="outline" size="sm" onClick={query.refresh}>Обновить выполнение</Button>
|
||
</div>
|
||
<p aria-live="polite" className="text-sm">
|
||
{operation ? operationStates[state] ?? state : query.data ? "Операция отсутствует в доступном журнале" : query.error ? "Состояние недоступно" : "Получаем состояние операции…"}
|
||
{query.error && operation && " · последнее полученное состояние"}
|
||
</p>
|
||
{operation && <>
|
||
{(state === "partial" || state === "unknown") && <div className="flex flex-wrap items-center gap-2 text-sm">
|
||
<p className="text-muted-foreground">Перед обратным изменением сверьте фактическое состояние. Повтор может повторно изменить конфигурацию.</p>
|
||
{can("mm:mcp:admin") && <Link className={buttonVariants({ variant: "outline", size: "sm" })} href={`/mcp?${recoveryLink}#mcp-recovery`}>Перейти к сверке</Link>}
|
||
</div>}
|
||
{steps.length > 0 && <section aria-label="Шаги выполнения" className="rounded-md border p-3 text-sm">
|
||
<h3 className="font-medium">Шаги выполнения</h3>
|
||
<ol className="mt-2 list-inside list-decimal space-y-1">
|
||
{steps.map(step => <li key={String(step.step_index)}>{stepStates[String(step.state)] ?? String(step.state)}{step.server_id != null && ` · сервер ${step.server_id}`}{step.finished_at != null && ` · ${date(step.finished_at)}`}</li>)}
|
||
</ol>
|
||
{operation.stepsTruncated === true && <p className="mt-2 text-muted-foreground">Показаны первые 100 шагов.</p>}
|
||
</section>}
|
||
<pre className="max-h-[45dvh] overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-3 text-xs">{pretty(operation)}</pre>
|
||
{!query.error && <ArtifactDownloads api={api} operation={operation} />}
|
||
</>}
|
||
</>
|
||
}
|
||
function RecordFilters({ fields, value, update }: { fields: readonly string[]; value: (field: string) => string; update: (field: string, value: string) => void }) {
|
||
return (
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
{fields.includes("connectionId") && <Field label="Фильтр подключения MCP"><Input value={value("connectionId")} placeholder="UUID подключения" onChange={event => update("connectionId", event.target.value)} /></Field>}
|
||
{fields.includes("serverId") && <Field label="Фильтр ID сервера"><Input type="number" min={1} value={value("serverId")} onChange={event => update("serverId", event.target.value)} /></Field>}
|
||
{fields.includes("userId") && <Field label="Фильтр пользователя"><Input value={value("userId")} onChange={event => update("userId", event.target.value)} /></Field>}
|
||
{["from", "to"].map(field => <Field key={field} label={field === "from" ? "Обращения с (UTC)" : "Обращения до (UTC)"}><Input type="datetime-local" value={value(field).slice(0, 16)} onChange={event => update(field, event.target.value ? new Date(event.target.value + "Z").toISOString() : "")} /></Field>)}
|
||
</div>
|
||
)
|
||
}
|
||
function ConnectionPolicySimulation({ api, connection, operations }: {
|
||
api: McpApi
|
||
connection: McpConnection
|
||
operations: McpOperationDescriptor[]
|
||
}) {
|
||
const [operationId, setOperationId] = useState(""), [serverText, setServerText] = useState("")
|
||
const [result, setResult] = useState<McpPolicyDecision | null>(null)
|
||
const revision = useRef(0)
|
||
useEffect(() => () => { revision.current++ }, [])
|
||
|
||
const available = operations.filter(operation => connection.policy.scope.operationIds.includes(operation.id))
|
||
const operation = available.find(operation => operation.id === operationId)
|
||
const target = mcpTargetSchema.safeParse(operation?.scope === "servers"
|
||
? { scope: "servers", serverIds: serverText.split(/[,\s]+/).filter(Boolean).map(value => /^\d+$/.test(value) ? Number(value) : NaN) }
|
||
: { scope: operation?.scope })
|
||
const invalidate = () => { revision.current++; setResult(null) }
|
||
const preview = async () => {
|
||
if (!operation || !target.success) return
|
||
const current = ++revision.current
|
||
setResult(null)
|
||
try {
|
||
const response = await api.preview({ connectionId: connection.id, operationId: operation.id, target: target.data })
|
||
if (revision.current === current) setResult(response.result)
|
||
} catch (error) {
|
||
if (revision.current === current) toast.error(errorText(error))
|
||
}
|
||
}
|
||
|
||
return <>
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<NativeSelect label="Операция" value={operationId} onChange={value => { invalidate(); setOperationId(value); setServerText("") }}>
|
||
<option value="">Выберите действие</option>
|
||
{available.map(operation => <option key={operation.id} value={operation.id}>{operation.title} · {operation.id}</option>)}
|
||
</NativeSelect>
|
||
{operation?.scope === "servers" && <Field label="ID серверов"><Input value={serverText} onChange={event => { invalidate(); setServerText(event.target.value) }} /></Field>}
|
||
</div>
|
||
{operation && <p className="text-sm text-muted-foreground">{{ connection: "Область операции — подключение", global: "Область операции — всё приложение", servers: "Область операции — выбранные серверы" }[operation.scope]}</p>}
|
||
<Button className="self-start" disabled={!target.success || !can("mm:mcp:admin")} onClick={() => { void preview() }}><ShieldIcon />Проверить</Button>
|
||
{result && <PolicyPreviewDecision result={result} />}
|
||
</>
|
||
}
|
||
|
||
function Permissions({ api, operations }: { api: McpApi; operations: McpOperationDescriptor[] }) {
|
||
const connections = useQuery(useCallback(signal => api.connections(signal), [api]))
|
||
const protections = useQuery(useCallback(signal => api.protections(signal), [api]))
|
||
const [connectionId, setConnectionId] = useState("")
|
||
const [kind, setKind] = useState("server"), [objectId, setObjectId] = useState("")
|
||
const [protection, setProtection] = useState("human-approval-required"), [reason, setReason] = useState("")
|
||
const [protectionServerText, setProtectionServerText] = useState("")
|
||
const selected = connections.data?.connections.find(connection => connection.id === connectionId)
|
||
const protectionItems = protections.data?.protections ?? []
|
||
const protectionServerId = Number(protectionServerText)
|
||
const validProtectionServer = /^\d+$/.test(protectionServerText) && Number.isSafeInteger(protectionServerId) && protectionServerId > 0
|
||
const addProtection = () => {
|
||
void api.protect({ kind, id: objectId, protection, ...(kind === "server" ? {} : { serverId: protectionServerId }) }, reason)
|
||
.then(protections.refresh, error => toast.error(errorText(error)))
|
||
}
|
||
return <div className="flex flex-col gap-4">
|
||
<Panel title="Проверка разрешений" description="Симуляция текущей политики подключения без выполнения операции. Защиту конкретного объекта можно проверить отдельно.">
|
||
<ErrorMessage message={connections.error || protections.error} />
|
||
<NativeSelect label="Подключение" value={connectionId} onChange={setConnectionId}>
|
||
<option value="">Выберите подключение</option>
|
||
{connections.data?.connections.map(connection => <option key={connection.id} value={connection.id}>{connection.name}</option>)}
|
||
</NativeSelect>
|
||
{selected && <ConnectionPolicySimulation key={`simulation:${selected.id}:${selected.policy.version}`} api={api} connection={selected} operations={operations} />}
|
||
{selected && selected.ownerId === getClaims()?.sub && <ObjectPermissionPreview key={`object:${selected.id}:${selected.policy.version}`} api={api} connection={selected} operations={operations} editable={can("mm:mcp:admin") && !protections.error && !!protections.data} onProtected={protections.refresh} />}
|
||
</Panel>
|
||
<Panel title="Защищённые объекты" description="Защита сохраняется и при автономном доступе">
|
||
<div className="flex flex-col gap-2">{protectionItems.map(item => <div key={item.id} className="flex flex-wrap items-center justify-between gap-2 rounded border p-3 text-sm"><span>{item.object_ref.kind} · {item.object_ref.id} · {item.object_ref.protection}<span className="ml-2 text-muted-foreground">{item.reason}</span></span>{can("mm:mcp:admin") && <Button variant="outline" size="sm" onClick={() => { if (window.confirm("Удалить защиту объекта?")) void api.removeProtection(item.id).then(protections.refresh, error => toast.error(errorText(error))) }}>Удалить защиту</Button>}</div>)}</div>
|
||
{can("mm:mcp:admin") && <>
|
||
<div className="grid gap-3 md:grid-cols-4">
|
||
<Field label="Тип объекта"><Input value={kind} onChange={event => setKind(event.target.value)} /></Field>
|
||
<Field label="Стабильный ID"><Input value={objectId} onChange={event => setObjectId(event.target.value)} /></Field>
|
||
{kind !== "server" && <Field label="ID сервера защищаемого объекта"><Input value={protectionServerText} onChange={event => setProtectionServerText(event.target.value)} /></Field>}
|
||
<NativeSelect label="Защита" value={protection} onChange={setProtection}><option value="human-approval-required">Подтверждение человеком</option><option value="deny">Запрет изменений</option></NativeSelect>
|
||
<Field label="Причина"><Input value={reason} onChange={event => setReason(event.target.value)} /></Field>
|
||
</div>
|
||
<Button className="self-start" disabled={!objectId || kind !== "server" && !validProtectionServer} onClick={addProtection}>Добавить защиту</Button>
|
||
</>}
|
||
</Panel>
|
||
</div>
|
||
}
|
||
function OperationRecovery({ api, operationId }: { api: McpApi; operationId: string }) {
|
||
const [id, setId] = useState(operationId), [result, setResult] = useState<unknown>(null), [error, setError] = useState(""), [busy, setBusy] = useState(false)
|
||
const target = id.trim()
|
||
const validId = mcpPageQuerySchema.shape.operationId.safeParse(target).success
|
||
const run = async (recover: boolean) => {
|
||
if (busy || !validId) return
|
||
setBusy(true); setError(""); setResult(null)
|
||
try { setResult(recover ? await api.recoverOperation(target) : await api.reconcileOperation(target)) } catch (failure) { setError(errorText(failure)) } finally { setBusy(false) }
|
||
}
|
||
return <section id="mcp-recovery">
|
||
<Panel title="Сверка и восстановление" description="Сверка читает состояние устройства. Восстановление создаёт отдельный план и проверяет отсутствие внешних изменений.">
|
||
<ErrorMessage message={error} />
|
||
<Field label="ID исходной операции"><Input value={id} disabled={busy} aria-invalid={!!target && !validId} onChange={event => { setId(event.target.value); setResult(null); setError("") }} /></Field>
|
||
{target && !validId && <p className="text-sm text-destructive">Укажите UUID операции из журнала.</p>}
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button variant="outline" disabled={busy || !validId} onClick={() => { void run(false) }}>Сверить состояние операции</Button>
|
||
<Button disabled={busy || !validId} onClick={() => { void run(true) }}>Подготовить обратный план</Button>
|
||
</div>
|
||
{result !== null && <pre role="status" className="max-h-60 overflow-auto whitespace-pre-wrap break-all text-xs">{pretty(result)}</pre>}
|
||
</Panel>
|
||
</section>
|
||
}
|
||
function ManagementChannels({ api }: { api: McpApi }) {
|
||
const channels = useQuery(useCallback(signal => api.managementChannels(signal), [api]))
|
||
const [serverId, setServerId] = useState(""), [host, setHost] = useState(""), [port, setPort] = useState(443), [ssl, setSsl] = useState(true), [username, setUsername] = useState(""), [password, setPassword] = useState(""), [attestation, setAttestation] = useState("")
|
||
const [expiry, setExpiry] = useState(() => new Date(Date.now() + 1800000).toISOString().slice(0, 16)), [busy, setBusy] = useState(false), [error, setError] = useState("")
|
||
const register = async () => {
|
||
setBusy(true); setError("")
|
||
try { await api.registerManagement({ serverId: Number(serverId), host, port, useSsl: ssl, verifySsl: true, username, password, attestation, expiresAt: new Date(expiry + "Z").toISOString() }); setPassword(""); channels.refresh(); toast.success("Канал проверен по идентичности RouterOS и зарегистрирован") } catch (failure) { setError(errorText(failure)) } finally { setBusy(false) }
|
||
}
|
||
return <Panel title="Независимый канал управления" description="Другой IP того же RouterOS. Backend проверяет стабильный ID и доступность; независимость сетевого пути подтверждает администратор."><ErrorMessage message={channels.error || error} /><Grid items={channels.data?.channels ?? []} columns={[{ accessorKey: "server_id", header: "Сервер" }, { accessorKey: "verified_at", header: "Проверен", cell: ({ row }) => date(row.original.verified_at) }, { accessorKey: "expires_at", header: "Допуск до", cell: ({ row }) => date(row.original.expires_at) }, { id: "action", header: "Доступ", cell: ({ row }) => row.original.revoked_at ? "Отозван" : <Button size="sm" variant="outline" onClick={() => { void api.revokeManagement(String(row.original.id)).then(channels.refresh, failure => toast.error(errorText(failure))) }}>Отозвать канал</Button> }]} />{can("mm:network:admin") && <><div className="grid gap-3 sm:grid-cols-2"><Field label="ID сервера независимого канала"><Input type="number" min={1} value={serverId} onChange={event => setServerId(event.target.value)} /></Field><Field label="IP независимого управления"><Input value={host} onChange={event => setHost(event.target.value)} /></Field><Field label="Порт независимого управления"><Input type="number" min={1} max={65535} value={port} onChange={event => setPort(Number(event.target.value))} /></Field><Choice label="HTTPS с проверкой сертификата" checked={ssl} onChange={setSsl} /><Field label="Пользователь независимого канала"><Input autoComplete="off" value={username} onChange={event => setUsername(event.target.value)} /></Field><Field label="Пароль независимого канала"><Input type="password" autoComplete="new-password" value={password} onChange={event => setPassword(event.target.value)} /></Field><Field label="Допуск независимого канала до (UTC)"><Input type="datetime-local" value={expiry} onChange={event => setExpiry(event.target.value)} /></Field><Field label="Подтверждение независимости пути"><Textarea value={attestation} onChange={event => setAttestation(event.target.value)} placeholder="Опишите отдельный путь, интерфейс/VRF и почему изменение основного пути его не затронет" /></Field></div><Button disabled={busy || !serverId || !host || !username || !password || attestation.trim().length < 30} onClick={() => { void register() }}>Проверить и зарегистрировать канал</Button></>}</Panel>
|
||
}
|
||
function Settings({ api }: { api: McpApi }) {
|
||
const query = useQuery(useCallback(signal => api.settings(signal), [api])), clients = useQuery(useCallback(signal => api.clients(signal), [api]))
|
||
const [name, setName] = useState(""), [redirect, setRedirect] = useState("")
|
||
const save = async (value: unknown) => { try { await api.saveSettings(value); query.refresh() } catch (error) { toast.error(errorText(error)) } }
|
||
return <div className="flex flex-col gap-4"><Panel title="Настройки MCP" description="По умолчанию внешний доступ выключен"><ErrorMessage message={query.error} />{query.data && <><Choice label="Разрешить обращения MCP" checked={query.data.enabled} onChange={enabled => { void save({ enabled }) }} /><div className="grid gap-3 sm:grid-cols-2"><Field label="Хранение обращений, дней"><Input type="number" min={1} max={365} defaultValue={query.data.requestsRetentionDays} onBlur={event => { const value = Number(event.target.value); if (value !== query.data?.requestsRetentionDays && window.confirm("Изменить срок хранения обращений? Старые записи будут удаляться по новому сроку.")) void save({ requestsRetentionDays: value }) }} /></Field><Field label="Хранение аудита, дней"><Input type="number" min={30} max={3650} defaultValue={query.data.auditRetentionDays} onBlur={event => { const value = Number(event.target.value); if (value !== query.data?.auditRetentionDays && window.confirm("Изменить срок хранения аудита?")) void save({ auditRetentionDays: value }) }} /></Field></div></>}</Panel><Panel title="OAuth-клиенты" description="Зарегистрируйте точный redirect URI внешнего AI-клиента"><ErrorMessage message={clients.error} />{clients.data?.clients.map(client => <div key={client.id} className="rounded border p-3 text-sm"><p className="font-medium">{client.name} · {client.enabled ? "разрешён" : "отключён"}</p><p className="break-all font-mono text-xs">client_id: {client.id}</p><p className="break-all text-muted-foreground">{client.redirect_uris.join(", ")}</p>{client.enabled && <Button size="sm" variant="outline" onClick={() => { if (window.confirm("Отключить OAuth-клиент и отозвать его токены?")) void api.disableClient(client.id).then(clients.refresh, error => toast.error(errorText(error))) }}>Отключить и отозвать токены</Button>}</div>)}<Field label="Название клиента"><Input value={name} onChange={event => setName(event.target.value)} /></Field><Field label="Redirect URI, по одному на строку"><Textarea value={redirect} onChange={event => setRedirect(event.target.value)} /></Field><Button className="self-start" disabled={!name || !redirect} onClick={() => { void api.createClient(name, redirect.split("\n").map(value => value.trim()).filter(Boolean)).then(() => { setName(""); setRedirect(""); clients.refresh() }, error => toast.error(errorText(error))) }}>Зарегистрировать клиент</Button></Panel><Records api={api} kind="audit" /></div>
|
||
}
|
||
function RestCoverage({ api }: { api: McpApi }) {
|
||
const query = useQuery(useCallback(signal => api.coverage(signal), [api])), routes = query.data?.routes ?? []
|
||
return <Panel title="Покрытие REST через MCP" description="Сервисные сценарии проверяются отдельно от количества инструментов. Новые endpoints отслеживает автоматическая проверка реестра."><ErrorMessage message={query.error} /><div className="flex flex-wrap gap-2"><Badge variant="success-light">Сценариев с MCP: {routes.filter(route => route.status === "mediated").length}</Badge><Badge variant="info-light">Транспорт и управление доступом: {routes.filter(route => route.status === "excluded").length}</Badge><Badge variant="warning-light">Ожидают реализации: {routes.filter(route => route.status === "pending").length}</Badge></div><Grid items={routes.filter(route => route.status === "pending")} columns={[{ accessorKey: "method", header: "Метод", size: 90 }, { accessorKey: "path", header: "Незавершённый сценарий", size: 310 }, { accessorKey: "reason", header: "Что осталось", size: 400 }]} loading={!query.data && !query.error} /></Panel>
|
||
}
|
||
function Consent({ api, id }: { api: McpApi; id: string }) {
|
||
const request = useQuery(useCallback(signal => api.consentRequest(id, signal), [api, id])), connections = useQuery(useCallback(signal => api.connections(signal), [api]))
|
||
const [selected, setSelected] = useState("")
|
||
const [busy, setBusy] = useState(false)
|
||
const submitting = useRef(false)
|
||
const mounted = useRef(false)
|
||
useEffect(() => {
|
||
mounted.current = true
|
||
return () => { mounted.current = false }
|
||
}, [])
|
||
const currentRequest = !request.error && request.data?.id === id ? request.data : null
|
||
const ownerId = getClaims()?.sub
|
||
const [now, setNow] = useState(() => Date.now())
|
||
const active = connections.error ? [] : connections.data?.connections.filter(connection => connection.ownerId === ownerId && connection.policy.status === "active" && connection.generationValid) ?? []
|
||
const nextExpiry = active.reduce((earliest, connection) => {
|
||
const deadline = connectionAccessDeadline(connection)
|
||
return deadline > now ? Math.min(earliest, deadline) : earliest
|
||
}, Infinity)
|
||
useEffect(() => {
|
||
if (!Number.isFinite(nextExpiry)) return
|
||
const delay = Math.max(0, nextExpiry - Date.now() + 1)
|
||
const timer = window.setTimeout(() => setNow(Date.now()), Math.min(delay, 60000))
|
||
return () => window.clearTimeout(timer)
|
||
}, [nextExpiry, now])
|
||
const eligible = active.filter(connection => connectionAccessDeadline(connection) > now)
|
||
const canDeny = currentRequest !== null
|
||
const canApprove = canDeny && eligible.some(connection => connection.id === selected)
|
||
const isCurrentLocation = () => mounted.current && new URLSearchParams(window.location.search).get("oauthRequest") === id
|
||
const confirm = async (approve: boolean) => {
|
||
if (!(approve ? canApprove : canDeny) || submitting.current || !isCurrentLocation()) return
|
||
if (approve && !eligible.some(connection => connection.id === selected && connectionAccessDeadline(connection) > Date.now())) return
|
||
submitting.current = true
|
||
setBusy(true)
|
||
try {
|
||
const result = await api.consent(id, approve ? { approve: true, connectionId: selected } : { approve: false })
|
||
if (isCurrentLocation()) window.location.assign(result.redirectUrl)
|
||
} catch (error) {
|
||
if (!mounted.current) return
|
||
submitting.current = false
|
||
setBusy(false)
|
||
if (isCurrentLocation()) toast.error(errorText(error))
|
||
}
|
||
}
|
||
return <Panel title="Разрешение OAuth-доступа" description="Выберите подключение с нужными ограничениями; клиент получит только его доступ">
|
||
<ErrorMessage message={request.error || connections.error} />
|
||
{currentRequest && <><p>{currentRequest.name} запрашивает доступ к MikrotikManager.</p><p className="break-all text-sm text-muted-foreground">Возврат: {currentRequest.redirect_uri}</p></>}
|
||
<NativeSelect label="Подключение" value={selected} onChange={setSelected}>
|
||
<option value="">Выберите ограниченный доступ</option>
|
||
{eligible.map(connection => <option key={connection.id} value={connection.id}>{connection.name} · {modeLabels[connection.policy.mode]}</option>)}
|
||
</NativeSelect>
|
||
<div className="flex gap-2">
|
||
<Button disabled={!canApprove || busy} onClick={() => { void confirm(true) }}>Разрешить</Button>
|
||
<Button variant="outline" disabled={!canDeny || busy} onClick={() => { void confirm(false) }}>Отклонить</Button>
|
||
</div>
|
||
</Panel>
|
||
}
|
||
export function McpWorkspace() {
|
||
const { backendUrl, mode } = useDataSource()
|
||
return <McpWorkspaceContent key={JSON.stringify([backendUrl, mode])} backendUrl={backendUrl} live={mode === "live"} />
|
||
}
|
||
function McpWorkspaceContent({ backendUrl, live }: { backendUrl: string; live: boolean }) {
|
||
const params = useSearchParams(), initial = params.get("tab") as Tab | null
|
||
const oauthRequestId = params.get("oauthRequest")
|
||
const [tab, setTab] = useState<Tab>(initial && initial in tabs ? initial : "overview")
|
||
const recoveryId = params.get("reconcile") ?? ""
|
||
const api = useMemo(() => mcpApi(backendUrl), [backendUrl])
|
||
const [operations, setOperations] = useState<McpOperationDescriptor[]>([]), [error, setError] = useState("")
|
||
useEffect(() => { if (!live) return; const controller = new AbortController(); void api.catalog(controller.signal).then(catalog => setOperations(catalog.operations), failure => { if (!controller.signal.aborted) setError(errorText(failure)) }); return () => controller.abort() }, [api, live])
|
||
const changeTab = (value: unknown) => { const next = String(value) as Tab; setTab(next); const url = new URL(window.location.href); url.searchParams.set("tab", next); window.history.replaceState(null, "", url.pathname + url.search) }
|
||
return <><PageHeader crumbs={[{ label: "MCP" }]} /><main className="flex min-w-0 flex-1 flex-col gap-5 p-4 md:p-6"><div className="flex items-center gap-3"><BotIcon className="size-7 text-muted-foreground" /><div><h1 className="text-xl font-semibold">MCP</h1><p className="text-sm text-muted-foreground">Доступ нейросетей, разрешения и контроль изменений</p></div></div>{!live ? <Panel title="Подключите backend"><p>Для выдачи реального AI-доступа выберите режим «Живые» и настройте авторизацию портала.</p></Panel> : <><ErrorMessage message={error} />{oauthRequestId && <Consent key={oauthRequestId} api={api} id={oauthRequestId} />}<Tabs value={tab} onValueChange={changeTab}><div className="overflow-x-auto pb-1"><TabsList variant="line">{Object.entries(tabs).filter(([key]) => key !== "settings" || can("mm:mcp:admin")).map(([key, title]) => <TabsTrigger key={key} value={key}>{title}</TabsTrigger>)}</TabsList></div><TabsContent value="overview"><Overview api={api} /></TabsContent><TabsContent value="connections"><div className="flex flex-col gap-4"><Connections api={api} operations={operations} />{can("mm:mcp:admin") && <AccessTools api={api} />}</div></TabsContent><TabsContent value="permissions"><Permissions api={api} operations={operations} /></TabsContent><TabsContent value="devices"><DeviceManagement api={api} /></TabsContent><TabsContent value="routing"><div className="flex flex-col gap-4"><RoutingProfiles api={api} /><RoutePathVerification api={api} /></div></TabsContent><TabsContent value="requests"><Records api={api} kind="requests" /></TabsContent><TabsContent value="plans"><div className="flex flex-col gap-4"><Records api={api} kind="plans" /><Records api={api} kind="operations" /><OperationRecovery key={recoveryId} api={api} operationId={recoveryId} /><Records api={api} kind="drift" /></div></TabsContent>{can("mm:mcp:admin") && <TabsContent value="settings"><div className="flex flex-col gap-4"><Settings api={api} /><RestCoverage api={api} /><ManagementChannels api={api} />{can("mm:settings:admin") && <DatabaseRestore api={api} />}</div></TabsContent>}</Tabs></>}</main></>
|
||
}
|