319 lines
82 KiB
TypeScript
319 lines
82 KiB
TypeScript
"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></>
|
||
}
|