"use client" import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react" 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, 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 } 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 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
{label}{reason}
} function useQuery(load: (signal: AbortSignal) => Promise) { const [data, setData] = useState(null), [error, setError] = useState("") const [updatedAt, setUpdatedAt] = useState(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() setError("Обновление не завершилось за минуту. Следующая попытка выполнится автоматически.") }, 60000) try { const result = await load(controller.signal); if (alive && requestRevision === revision) { setData(result); setError(""); setUpdatedAt(new Date().toISOString()) } } catch (failure) { if (alive && requestRevision === revision && !controller.signal.aborted) setError(errorText(failure)) } 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]) return { data, error, refresh, updatedAt } } function Panel({ title, description, children, actions }: { title: string; description?: string; children: ReactNode; actions?: ReactNode }) { return
{title}{description && {description}}
{actions}
{children} } function ErrorMessage({ message }: { message: string }) { return message ?

{message}

: null } function Field({ label, children }: { label: string; children: ReactNode }) { return } function Choice({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) { return } function NativeSelect({ label, value, onChange, children }: { label: string; value: string; onChange: (value: string) => void; children: ReactNode }) { return } function Grid({ items, columns, loading, total, onSelect }: { items: RecordRow[]; columns: ColumnDef[]; 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 } type DeviceResource = Awaited>["resources"][number] function DeviceManagement({ api }: { api: McpApi }) { const connections = useQuery(useCallback(signal => api.connections(signal), [api])) const [connectionId, setConnectionId] = useState("") return {connections.data?.connections.filter(connection => connection.ownerId === getClaims()?.sub && connection.policy.status === "active").map(connection => )}{connectionId && } } 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 <>
{catalog.data?.servers.map(server => )}{catalog.data?.resources.filter(resource => resource.read).map(resource => )}
{catalog.data && !catalog.data.resources.some(resource => resource.read) &&

Подключению не выданы инструменты чтения интерфейсов.

}{resourceId === "bridge" && serverId && catalog.data?.servers.some(server => String(server.id) === serverId && server.enabled) && }{resource && serverId && catalog.data?.servers.some(server => String(server.id) === serverId && server.enabled) && } } function DeviceEditor({ api, connectionId, serverId, resource }: { api: McpApi; connectionId: string; serverId: string; resource: DeviceResource }) { const [offset, setOffset] = useState(0), [editing, setEditing] = useState(null), [draft, setDraft] = useState>({}) 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 <>{receipt &&

{receipt}

}

Объектов: {rows.data?.total ?? "…"}. Выберите строку для просмотра и подготовки изменения.

{resource.create && }
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) }} />
{editing &&

{editing === "new" ? "Новый объект" : `Объект ${String(current?.[".id"])}`}

{current &&
Текущие свойства
{pretty(current)}
}{protectedRow ?

Системный или динамический объект доступен для чтения.

: <>

В план попадут только заполненные изменения. Пустой выбор оставляет свойство без изменения; при создании действует настройка устройства.

{editableFields.map(field => {field.type === "boolean" || field.options ? : change(field.key, field.type === "number" ? event.target.value === "" ? undefined : Number(event.target.value) : event.target.value)} />})}
{ethernetDisabledUnknown && draft.disabled !== undefined &&

Исходное состояние неизвестно. Для этого изменения обратный план недоступен.

} }
{!protectedRow && (editing === "new" ? resource.create : resource.update) && }{current && !protectedRow && resource.delete && }
} } function TokenDialog({ token, close, connectionName }: { token: string; close: () => void; connectionName?: string }) { return { if (!open) close() }}>Доступ создан{connectionName && `Подключение «${connectionName}». `}Сохраните токен в настройках AI-клиента. Повторно посмотреть его нельзя. } 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(editing?.policy.mode ?? "observer") const [serverText, setServerText] = useState(editing?.policy.scope.serverIds.join(", ") ?? ""), [selected, setSelected] = useState(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 { if (!open && !busy) close() }}>{editing ? "Изменить разрешения" : "Новое подключение"}Шаг {step} из 4 · доступ ограничивается правами текущего владельца. {step === 1 && <> setName(event.target.value)} placeholder="Агент диагностики" /> setExpires(event.target.value)} /> setMode(value as typeof mode)}>{Object.entries(modeLabels).map(([value, title]) => )}} {step === 2 && <> setServerText(event.target.value)} placeholder="1, 2" />
{eligible.map(operation => toggle(operation.id)} />)}

Новые инструменты не добавляются в доступ автоматически. Для применения планов выберите также инструменты просмотра и применения.

} {step === 3 && <>
{Object.entries(limits).map(([key, value]) => )[key]}> setLimits(current => ({ ...current, [key]: Number(event.target.value) }))} />)}
setDanger(value as typeof danger)}>{danger === "explicit-allow" && eligible.filter(operation => operation.risk === "dangerous" && selected.includes(operation.id)).map(operation => setDangerIds(ids => checked ? [...ids, operation.id] : ids.filter(id => id !== operation.id))} />)}} {step === 4 &&

{name || "Без названия"} · {modeLabels[mode]}

Серверы: {serverText || "не выбраны"}. Операций: {selected.length}. Глобальный доступ: {global ? "да" : "нет"}.

Защита объектов продолжает действовать независимо от автономных разрешений.

После сохранения проверьте конкретное действие в симуляторе на вкладке «Разрешения».

}
{step < 4 ? : }
} function Connections({ api, operations }: { api: McpApi; operations: McpOperationDescriptor[] }) { const query = useQuery(useCallback(signal => api.connections(signal), [api])) const [editing, setEditing] = useState(null), [token, setToken] = useState("") const admin = can("mm:mcp:admin") const refresh = query.refresh const act = useCallback(async (work: () => Promise) => { try { await work(); refresh() } catch (error) { toast.error(errorText(error)) } }, [refresh]) const columns = useMemo[]>(() => [ { 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 }) => }, { 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
{date(connection.ownerValidUntil)}{connection.ownerId === getClaims()?.sub && }
} }, { id: "actions", header: "Действия", size: 350, cell: ({ row }) => { const connection = row.original as unknown as McpConnection; return admin &&
} }, ], [api, admin, act]) return <> setEditing("new")}>Выдать доступ}>

HTTP endpoint: адрес backend + /mcp. Для stdio используйте MCP_URL и MCP_TOKEN с командой npm --prefix backend run mcp:stdio.

{editing && setEditing(null)} created={value => { setEditing(null); if (value) setToken(value); query.refresh() }} />} 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[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 {connections.data?.connections.map(connection => )} {connection && } {issued && { setIssued(null); releaseGrant() }} />} } function ConnectionAccessTools({ api, connection, issue, grantBusy }: { api: McpApi connection: McpConnection issue: (grant: Parameters[1]) => Promise grantBusy: boolean }) { const [result, setResult] = useState> | null>(null) const [operations, setOperations] = useState([]), [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 <>

Права владельца подтверждены до {date(connection.ownerValidUntil)}. Для продления войдите с актуальными правами портала; старые планы и допуски контроллера потребуется подготовить заново.

{result !== null && <> {result.protocolProbe?.capabilitiesRead &&

{({ passed: "Проверочное чтение возможностей прошло.", failed: "Проверочное чтение возможностей завершилось ошибкой.", skipped: "Проверочное чтение возможностей пропущено." })[result.protocolProbe.capabilitiesRead]}

}
{pretty(result)}
}
setServerText(event.target.value)} placeholder="ID через запятую" /> setMinutes(Number(event.target.value))} />
{connection.policy.scope.allowGlobal && }
{connection.policy.scope.operationIds.map(id => setOperations(values => checked ? [...values, id] : values.filter(value => value !== id))} />)}
{grants.data?.grants.map(grant =>
До {date(grant.grant.expiresAt)} · {grant.revoked_at ? "отозван" : "выдан"}
)} } 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(original?.excludedCandidateIds ?? []) const [sla, setSla] = useState({ ping: original?.maxPingMs?.toString() ?? "", loss: original?.maxLossPct?.toString() ?? "", speed: original?.minBandwidthMbps?.toString() ?? "" }) const [candidates, setCandidates] = useState(() => 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 { if (!open && !busy) close() }}>{editing ? "Изменить профиль Route AI" : "Новый профиль Route AI"}Проверьте пути и допуск. Сохранение создаёт план для отдельного подтверждения и применения.{plan ? <>

Создан план {plan}. Откройте «Планы и операции», проверьте изменения, подтвердите и примените план.

: <>
{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 => )} setName(event.target.value)} /> setServerId(event.target.value)} /> setCommunity(event.target.value)} placeholder="65000:10" /> setFamily(value as typeof family)}> setTable(event.target.value)} /> setProbeVrf(event.target.value)} />

Таблица определяет установку маршрутов. VRF диагностических проб определяет контекст измерений и задаётся отдельно от транспортной VRF BGP.

{bindBgp ?
Контекст BGP
{([{ key: "connectionId", title: "ID BGP-подключения RouterOS" }, { key: "instance", title: "Экземпляр BGP" }, { key: "inputChain", title: "Входящая цепочка BGP" }, { key: "vrf", title: "Транспортная VRF BGP" }] as const).map(field => setBgp(values => ({ ...values, [field.key]: event.target.value }))} />)}

Заполните все поля. Снятие привязки явно удалит контекст BGP из плана профиля.

:

Подключение BGP будет определено по наблюдаемой конфигурации; неоднозначная привязка блокирует переключение.

} {controllerReason &&

{controllerReason} Профиль можно сохранить с выключенным контроллером для анализа и подготовки предложений.

} {candidates.map((candidate, index) =>
Путь {index + 1} · {candidate.id}
{([{ 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 => setCandidates(values => values.map(value => value.id === candidate.id ? { ...value, [field.key]: event.target.value } : value))} />)} setCandidates(values => values.map(value => value.id === candidate.id ? { ...value, gatewayRef: { ...value.gatewayRef, resource: resource as typeof value.gatewayRef.resource } } : value))}> setCandidates(values => values.map(value => value.id === candidate.id ? { ...value, gatewayRef: { ...value.gatewayRef, id: event.target.value } } : value))} />
setExcluded(values => checked ? [...values, candidate.id] : values.filter(value => value !== candidate.id))} />
)} setCandidates(values => values.map(candidate => candidate.id === id ? { ...candidate, controlProbe } : candidate))} />
{Object.entries(weights).map(([key, value]) => setWeights(values => ({ ...values, [key]: Number(event.target.value) }))} />)}{([{ key: "ping", title: "Максимум RTT, мс" }, { key: "loss", title: "Максимум потерь, %" }, { key: "speed", title: "Минимум скорости, Mbps" }] as const).map(field => setSla(values => ({ ...values, [field.key]: event.target.value }))} />)} setThreshold(Number(event.target.value))} /> setCooldown(Number(event.target.value))} />{candidates.map((candidate, index) => )}
setExpires(event.target.value)} />}
} type CandidateForm = Omit & { jumpHostId: string; exitNodeId: string } function CandidateProbeEditor({ candidates, update }: { candidates: CandidateForm[]; update: (id: string, probe: CandidateForm["controlProbe"]) => void }) { return
{candidates.map((candidate, index) =>
Контрольная проверка пути {index + 1} 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 && <> update(candidate.id, { ...candidate.controlProbe!, targetInterface: event.target.value })} /> update(candidate.id, { ...candidate.controlProbe!, sourceAddress: event.target.value || undefined })} /> update(candidate.id, { ...candidate.controlProbe!, targetAddress: event.target.value || undefined })} />{candidate.controlProbe.expectedHops.map((hop, hopIndex) =>
update(candidate.id, { ...candidate.controlProbe!, expectedHops: candidate.controlProbe!.expectedHops.map((item, current) => current === hopIndex ? { ...item, serverId: Number(event.target.value) } : item) })} /> update(candidate.id, { ...candidate.controlProbe!, expectedHops: candidate.controlProbe!.expectedHops.map((item, current) => current === hopIndex ? { ...item, address: event.target.value || undefined } : item) })} /> update(candidate.id, { ...candidate.controlProbe!, expectedHops: candidate.controlProbe!.expectedHops.map((item, current) => current === hopIndex ? { ...item, interface: event.target.value } : item) })} />
)}

Проверяются зарегистрированные адреса, порядок hops и достижение ExitNode. Это наблюдение контрольных пакетов; оно не доказывает путь всего пользовательского трафика.

}
)}
} 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
{ invalidate(); setProfileId(value); setCandidateId("") }}>{profiles.data?.profiles.map(profile => )} { invalidate(); setCandidateId(value) }}>{candidates.map(candidate => )} { invalidate(); setProofPurpose(value as typeof proofPurpose) }}>

{proofPurpose === "control-path" ? "Проверяется только контрольный путь. Результат не разрешает переключение Community и не подтверждает обратный путь." : "Проверка учитывает Community, таблицу маршрутизации и BGP. Применение изменений требует отдельного плана; обратный путь проверяется отдельно."}

{currentFeedback && "result" in currentFeedback &&
{pretty(currentFeedback.result)}
}
} function RoutingProfiles({ api }: { api: McpApi }) { const query = useQuery(useCallback(signal => api.profiles(signal), [api])), [selected, setSelected] = useState(null) const [editing, setEditing] = useState(null) const columns = useMemo[]>(() => [ { 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 }) => {row.original.enabled ? "включён" : "пауза"} }, { 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") && }, ], [api, query.refresh]) return <> setEditing("new")}>Создать профиль}>

Контроллер учитывает свежесть измерений, закрепления, SLA, гистерезис, cooldown и бюджет переключений. Неизвестный фактический путь не считается подтверждённым.

{ if (!open) setSelected(null) }}>Профиль Route AIСохранённые параметры и последнее решение контроллера
{pretty(selected)}
{can("mm:mcp:admin") && can("mm:network:write") && }
{editing && { setEditing(null); query.refresh() }} />} } function Overview({ api }: { api: McpApi }) { const query = useQuery(useCallback(signal => api.overview(signal), [api])) return { if (window.confirm("Остановить новые обращения MCP и все профили Route AI? Начатые записи будут завершены или сверены.")) void api.stopAutomation().then(query.refresh, error => toast.error(errorText(error))) }}>Остановить автоматические изменения}>
{Object.entries({ connections: "Подключения", calls: "Вызовы за 24 часа", errors: "Ошибки и отказы", approvals: "Ожидают подтверждения", running: "Выполняются" }).map(([key, title]) =>

{title}

{query.data ? query.data[key as "calls"] : "—"}

)}
{query.data?.enabled ? "MCP включён" : "MCP выключен"}

Выключатель прекращает новые вызовы. Выключатель также останавливает новые решения Route AI. Отдельный профиль можно остановить на вкладке Route AI.

} function Records({ api, kind }: { api: McpApi; kind: "requests" | "plans" | "operations" | "audit" | "drift" }) { const urlParams = useSearchParams(), [selected, setSelected] = useState(null) const search = urlParams.get(kind + ".tool") ?? "", status = urlParams.get(kind + ".status") ?? "", cursor = urlParams.get(kind + ".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 setSearch = (value: string) => setFilter("tool", value), setStatus = (value: string) => setFilter("status", value), setCursor = (value: string) => setFilter("cursor", value) const queryFields = new URLSearchParams({ limit: "50" }) for (const field of ["tool", "status", "cursor", "connectionId", "serverId", "userId", "from", "to"]) { const value = urlParams.get(kind + "." + 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) const columns = useMemo[]>(() => [ { 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 }) => {String(row.original.status ?? row.original.state ?? "событие")} }, { accessorKey: "connection_id", header: "Подключение", size: 250, cell: ({ row }) => {String(row.original.connection_id ?? row.original.connectionId ?? "—")} }, { accessorKey: "error_code", header: "Причина", cell: ({ row }) => String(row.original.error_code ?? row.original.verification ?? "—") }, { id: "details", header: "", cell: ({ row }) => }, ], []) const pendingPlanRequests = useRef(new Set()) const [pendingPlans, setPendingPlans] = useState>(() => new Set()) const selectedPlanPending = kind === "plans" && !!selected && pendingPlans.has(String(selected.id)) const submitPlan = async (action: "approve" | "reject" | "apply") => { if (!selected) 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") { await api.apply(planId) 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)) } } return

Последнее успешное обновление: {date(query.updatedAt)}{query.error ? " · показаны ранее полученные данные" : ""}

{kind === "requests" && { setSearch(event.target.value); setCursor("") }} />} { setStatus(event.target.value); setCursor("") }} />
Найдено: {query.data?.total ?? "—"}
{ if (!open) setSelected(null) }}>Детали {kind === "plans" ? "плана" : kind === "operations" ? "операции" : "обращения"}Секретные поля скрыты. Подтверждение относится к этому содержимому.{kind === "plans" && selected ? :
{pretty(selected)}
}{kind === "operations" && selected && }{kind === "plans" && ["approved", "draft"].includes(String(selected?.state)) && can("mm:mcp:admin") && }{kind === "plans" && selected?.state === "awaiting-approval" && can("mm:mcp:approve") &&
}
} function RecordFilters({ kind }: { kind: string }) { const params = useSearchParams() const update = (field: string, value: string) => { const url = new URL(window.location.href); if (value) url.searchParams.set(kind + "." + field, value); else url.searchParams.delete(kind + "." + field); url.searchParams.delete(kind + ".cursor"); window.history.replaceState(null, "", url.pathname + url.search) } return
update("connectionId", event.target.value)} />{kind === "requests" && <> update("serverId", event.target.value)} /> update("userId", event.target.value)} />}{["from", "to"].map(field => update(field, event.target.value ? new Date(event.target.value + "Z").toISOString() : "")} />)}
} function ConnectionPolicySimulation({ api, connection, operations }: { api: McpApi connection: McpConnection operations: McpOperationDescriptor[] }) { const [operationId, setOperationId] = useState(""), [serverText, setServerText] = useState("") const [result, setResult] = useState(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 <>
{ invalidate(); setOperationId(value); setServerText("") }}> {available.map(operation => )} {operation?.scope === "servers" && { invalidate(); setServerText(event.target.value) }} />}
{operation &&

{{ connection: "Область операции — подключение", global: "Область операции — всё приложение", servers: "Область операции — выбранные серверы" }[operation.scope]}

} {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
{connections.data?.connections.map(connection => )} {selected && } {selected && selected.ownerId === getClaims()?.sub && }
{protectionItems.map(item =>
{item.object_ref.kind} · {item.object_ref.id} · {item.object_ref.protection}{item.reason}{can("mm:mcp:admin") && }
)}
{can("mm:mcp:admin") && <>
setKind(event.target.value)} /> setObjectId(event.target.value)} /> {kind !== "server" && setProtectionServerText(event.target.value)} />} setReason(event.target.value)} />
}
} function OperationRecovery({ api }: { api: McpApi }) { const [id, setId] = useState(""), [result, setResult] = useState(null), [error, setError] = useState(""), [busy, setBusy] = useState(false) const run = async (recover: boolean) => { setBusy(true); setError(""); setResult(null) try { setResult(recover ? await api.recoverOperation(id) : await api.reconcileOperation(id)) } catch (failure) { setError(errorText(failure)) } finally { setBusy(false) } } return { setId(event.target.value); setResult(null) }} />
{can("mm:mcp:admin") && }
{result !== null &&
{pretty(result)}
}
} 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 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 ? "Отозван" : }]} />{can("mm:network:admin") && <>
setServerId(event.target.value)} /> setHost(event.target.value)} /> setPort(Number(event.target.value))} /> setUsername(event.target.value)} /> setPassword(event.target.value)} /> setExpiry(event.target.value)} />