Files
MikrotikManager/components/mcp/mcp-workspace.tsx
T

319 lines
82 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useCallback, useEffect, useMemo, 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, type McpConnection, type McpOperationDescriptor } from "@mmapp/contracts/mcp"
import { 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 { 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<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 useQuery<T>(load: (signal: AbortSignal) => Promise<T>) {
const [data, setData] = useState<T | null>(null), [error, setError] = useState("")
const [updatedAt, setUpdatedAt] = useState<string | null>(null)
const refresh = useCallback(() => { window.dispatchEvent(new Event("mcp-refresh")) }, [])
useEffect(() => {
let alive = true, revision = 0, controller: AbortController | undefined
const run = async () => {
if (document.hidden) return
controller?.abort(); controller = new AbortController(); const requestRevision = ++revision
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)) }
}
queueMicrotask(() => { void run() })
const tick = () => { void run() }, timer = window.setInterval(tick, 5000)
window.addEventListener("mcp-refresh", tick); document.addEventListener("visibilitychange", tick)
return () => { alive = false; revision++; controller?.abort(); window.clearInterval(timer); window.removeEventListener("mcp-refresh", tick); 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 <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>}{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 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">{resource.fields.filter(field => editing === "new" || field.key in (current ?? {})).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="">{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></>}<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 }: { token: string; close: () => void }) {
return <Dialog open={!!token} onOpenChange={open => { if (!open) close() }}><DialogContent className="sm:max-w-xl"><DialogHeader><DialogTitle>Доступ создан</DialogTitle><DialogDescription>Сохраните токен в настройках 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: "Состояние", cell: ({ row }) => <Badge variant={(row.original.policy as McpConnection["policy"]).status === "active" && row.original.generationValid ? "success-light" : "warning-light"}>{row.original.generationValid ? (row.original.policy as McpConnection["policy"]).status : "доступ аннулирован"}</Badge> },
{ 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(""), [result, setResult] = useState<unknown>(null), [token, setToken] = useState("")
const [operations, setOperations] = useState<string[]>([]), [serverText, setServerText] = useState(""), [minutes, setMinutes] = useState(60), [global, setGlobal] = useState(false)
const grantLoad = useCallback(async (signal: AbortSignal) => selected ? api.grants(selected, signal) : { grants: [] }, [api, selected])
const grants = useQuery(grantLoad), connection = connections.data?.connections.find(value => value.id === selected)
const create = async () => {
if (!connection) return
try {
const now = new Date(), expires = new Date(Math.min(now.getTime() + minutes * 60000, Date.parse(connection.policy.expiresAt)))
const issued = await api.grant(connection.id, { 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 } })
setToken(issued.token); grants.refresh()
} catch (error) { toast.error(errorText(error)) }
}
return <Panel title="Проверка подключения и временные допуски" description="Допуск с отдельным токеном может только сузить основной доступ и действует не более 24 часов"><ErrorMessage message={connections.error || grants.error} /><NativeSelect label="Подключение для проверки" value={selected} onChange={value => { setSelected(value); setOperations([]); setServerText(""); setGlobal(false); setResult(null) }}><option value="">Выберите подключение</option>{connections.data?.connections.map(connection => <option key={connection.id} value={connection.id}>{connection.name}</option>)}</NativeSelect>{connection && <><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(connection.id).then(() => { connections.refresh(); toast.success("Права владельца обновлены") }, error => toast.error(errorText(error))) }}>Подтвердить текущие права владельца</Button><Button variant="outline" className="self-start" onClick={() => { void api.check(connection.id).then(setResult, error => toast.error(errorText(error))) }}>Проверить готовность доступа</Button>{result !== null && <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={!operations.length || connection.policy.status !== "active"} onClick={() => { void 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>)}</>}<TokenDialog token={token} close={() => setToken("")} /></Panel>
}
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 executionReason = table.trim() !== "main" || probeVrf.trim() !== "main"
? "Автоматическое переключение для отдельной таблицы маршрутов или VRF проб пока недоступно."
: bindBgp && (bgp.inputChain.trim() !== "bgp-in" || bgp.vrf.trim() !== "main")
? "Автоматическое переключение для этой входящей цепочки BGP или транспортной VRF пока недоступно."
: ""
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 controllerReason = autoRecovery ? "Автовосстановление Community пока недоступно. Отключите его для активации контроллера." : executionReason
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 [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), [error, setError] = useState(""), [result, setResult] = useState<unknown>(null)
const [proofPurpose, setProofPurpose] = useState<"community" | "control-path">("community")
const profile = profiles.data?.profiles.find(profile => profile.id === profileId), config = routeAiProfileSchema.safeParse(profile?.config)
const check = async () => { setBusy(true); setError(""); setResult(null); try { setResult(await api.checkRoutePath(profileId, candidateId, proofPurpose)) } catch (failure) { setError(errorText(failure)) } finally { setBusy(false) } }
return <Panel title="Контрольная проверка пути" description="Ограниченные ping и traceroute; неизвестный результат не считается отказом пути.">
<ErrorMessage message={profiles.error || error} />
<div className="grid gap-3 sm:grid-cols-2">
<NativeSelect label="Профиль для проверки пути" value={profileId} onChange={value => { setProfileId(value); setCandidateId(""); setResult(null) }}><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 => { setCandidateId(value); setResult(null) }}><option value="">Выберите путь</option>{config.success && config.data.candidates.filter(candidate => candidate.controlProbe).map(candidate => <option key={candidate.id} value={candidate.id}>{candidate.id}</option>)}</NativeSelect>
<NativeSelect label="Назначение проверки" value={proofPurpose} onChange={value => { setProofPurpose(value as typeof proofPurpose); setResult(null) }}><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 || !profileId || !candidateId} onClick={() => { void check() }}>Проверить контрольный путь</Button>
{result !== null && <pre role="status" className="max-h-72 overflow-auto whitespace-pre-wrap break-all text-xs">{pretty(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>
}
function Records({ api, kind }: { api: McpApi; kind: "requests" | "plans" | "operations" | "audit" | "drift" }) {
const urlParams = useSearchParams(), [selected, setSelected] = useState<RecordRow | null>(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<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={() => setSelected(row.original)}>Открыть</Button> },
], [])
const decision = async (approve: boolean) => { if (!selected) return; try { await api.decision(String(selected.id), approve); setSelected(null); query.refresh() } catch (error) { toast.error(errorText(error)) } }
return <Panel title={kind === "requests" ? "Обращения" : kind === "plans" ? "Планы изменений" : kind === "operations" ? "Выполнение операций" : kind === "drift" ? "Расхождения конфигурации" : "Административный аудит"} description="Чтение, отказы и изменения доступны для расследования"><ErrorMessage message={query.error} /><p className="text-xs text-muted-foreground">Последнее успешное обновление: {date(query.updatedAt)}{query.error ? " · показаны ранее полученные данные" : ""}</p><RecordFilters kind={kind} /><div className="flex flex-wrap gap-2">{kind === "requests" && <Input className="max-w-sm" aria-label="Имя инструмента" placeholder="Имя инструмента" value={search} onChange={event => { setSearch(event.target.value); setCursor("") }} />}<Input className="max-w-xs" aria-label="Фильтр состояния" placeholder="Состояние" value={status} onChange={event => { setStatus(event.target.value); setCursor("") }} /><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={setSelected} /><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={() => setCursor("")}>В начало</Button><Button variant="outline" disabled={!query.data?.nextCursor} onClick={() => setCursor(query.data?.nextCursor ?? "")}>Далее</Button></div></div><Dialog open={!!selected} onOpenChange={open => { if (!open) setSelected(null) }}><DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-3xl"><DialogHeader><DialogTitle>Детали {kind === "plans" ? "плана" : kind === "operations" ? "операции" : "обращения"}</DialogTitle><DialogDescription>Секретные поля скрыты. Подтверждение относится к этому содержимому.</DialogDescription></DialogHeader>{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>}{kind === "operations" && selected && <ArtifactDownloads key={String(selected.id)} api={api} operation={selected} />}{kind === "plans" && ["approved", "draft"].includes(String(selected?.state)) && can("mm:mcp:admin") && <Button onClick={() => { if (selected) void api.apply(String(selected.id)).then(() => { setSelected(null); query.refresh(); toast.success("Операция принята; результат появится в списке операций") }, error => toast.error(errorText(error))) }}>Применить план</Button>}{kind === "plans" && selected?.state === "awaiting-approval" && can("mm:mcp:approve") && <div className="flex gap-2"><Button onClick={() => { void decision(true) }}>Подтвердить план</Button><Button variant="outline" onClick={() => { void decision(false) }}>Отклонить</Button></div>}</DialogContent></Dialog></Panel>
}
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 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"><Field label="Фильтр подключения MCP"><Input value={params.get(kind + ".connectionId") ?? ""} placeholder="UUID подключения" onChange={event => update("connectionId", event.target.value)} /></Field>{kind === "requests" && <><Field label="Фильтр ID сервера"><Input type="number" min={1} value={params.get(kind + ".serverId") ?? ""} onChange={event => update("serverId", event.target.value)} /></Field><Field label="Фильтр пользователя"><Input value={params.get(kind + ".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={(params.get(kind + "." + field) ?? "").slice(0, 16)} onChange={event => update(field, event.target.value ? new Date(event.target.value + "Z").toISOString() : "")} /></Field>)}</div>
}
function Permissions({ api }: { api: McpApi }) {
const connections = useQuery(useCallback(signal => api.connections(signal), [api])), protections = useQuery(useCallback(signal => api.protections(signal), [api]))
const [connectionId, setConnectionId] = useState(""), [operationId, setOperationId] = useState(""), [serverText, setServerText] = useState(""), [result, setResult] = useState<unknown>(null)
const [kind, setKind] = useState("server"), [objectId, setObjectId] = useState(""), [protection, setProtection] = useState("human-approval-required"), [reason, setReason] = useState("")
const selected = connections.data?.connections.find(connection => connection.id === connectionId)
const preview = async () => { if (!selected) return; try { const serverIds = serverText.split(/[,\s]+/).filter(Boolean).map(Number); const res = await api.preview({ operationId, policy: selected.policy, ownerPermissions: getClaims()?.permissions ?? [], target: serverIds.length ? { scope: "servers", serverIds } : { scope: "global" }, objects: [], protections: protections.data?.protections.map(item => item.object_ref) ?? [] }); setResult(res.result) } catch (error) { toast.error(errorText(error)) } }
return <div className="flex flex-col gap-4"><Panel title="Проверка разрешений" description="Гипотетическое решение без выполнения действий и резервирования бюджета"><ErrorMessage message={connections.error || protections.error} /><div className="grid gap-3 md:grid-cols-3"><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><NativeSelect label="Операция" value={operationId} onChange={setOperationId}><option value="">Выберите действие</option>{selected?.policy.scope.operationIds.map(id => <option key={id}>{id}</option>)}</NativeSelect><Field label="ID серверов; пусто — глобальное действие"><Input value={serverText} onChange={event => setServerText(event.target.value)} /></Field></div><Button className="self-start" disabled={!selected || !operationId || !can("mm:mcp:admin")} onClick={() => { void preview() }}><ShieldIcon />Проверить</Button>{result !== null && <pre role="status" className="overflow-auto whitespace-pre-wrap rounded bg-muted p-3 text-xs">{pretty(result)}</pre>}</Panel><Panel title="Защищённые объекты" description="Защита сохраняется и при автономном доступе"><div className="flex flex-col gap-2">{protections.data?.protections.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><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} onClick={() => { void api.protect({ kind, id: objectId, protection, ...(kind === "server" ? {} : { serverId: Number(serverText) }) }, reason).then(protections.refresh, error => toast.error(errorText(error))) }}>Добавить защиту</Button></>}</Panel></div>
}
function OperationRecovery({ api }: { api: McpApi }) {
const [id, setId] = useState(""), [result, setResult] = useState<unknown>(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 <Panel title="Сверка и восстановление" description="Сверка читает состояние устройства. Восстановление создаёт отдельный план и проверяет отсутствие внешних изменений."><ErrorMessage message={error} /><Field label="ID исходной операции"><Input value={id} onChange={event => { setId(event.target.value); setResult(null) }} /></Field><div className="flex flex-wrap gap-2"><Button variant="outline" disabled={busy || !id} onClick={() => { void run(false) }}>Сверить состояние операции</Button>{can("mm:mcp:admin") && <Button disabled={busy || !id} 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>
}
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 confirm = async (approve: boolean) => { try { const result = await api.consent(id, selected, approve); window.location.assign(result.redirectUrl) } catch (error) { toast.error(errorText(error)) } }
return <Panel title="Разрешение OAuth-доступа" description="Выберите подключение с нужными ограничениями; клиент получит только его доступ"><ErrorMessage message={request.error || connections.error} /><p>{request.data?.name} запрашивает доступ к MikrotikManager.</p><p className="break-all text-sm text-muted-foreground">Возврат: {request.data?.redirect_uri}</p><NativeSelect label="Подключение" value={selected} onChange={setSelected}><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} · {modeLabels[connection.policy.mode]}</option>)}</NativeSelect><div className="flex gap-2"><Button disabled={!selected} onClick={() => { void confirm(true) }}>Разрешить</Button><Button variant="outline" disabled={!selected} 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 [tab, setTab] = useState<Tab>(initial && initial in tabs ? initial : "overview")
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} />{params.get("oauthRequest") && <Consent api={api} id={params.get("oauthRequest")!} />}<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} /></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 api={api} /><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></>
}