fix(mcp): довести базовые сценарии и проверку маршрутизации

This commit is contained in:
Codex committed 2026-11-11 20:28:37 +07:00
1 parent 1f0704add0
commit 04225886e0
101 files changed
+6118 -329

No files matched your search

+303 -48
View File
@@ -5,7 +5,7 @@ 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 { mcpConnectionCreateSchema, mcpTargetSchema, type McpConnection, type McpOperationDescriptor, type McpPolicyDecision } from "@mmapp/contracts/mcp"
import { routeAiControllerBlockReason, routeAiProfileSchema, type RouteAiProfile } from "@mmapp/contracts/mcp-routing"
import { useDataSource } from "@/lib/data-source"
import { can, getClaims, hasPermission } from "@/lib/auth"
@@ -16,6 +16,8 @@ import { DatabaseRestore } from "./database-restore"
import { PlanReview } from "./plan-review"
import { ArtifactDownloads } from "./artifact-downloads"
import { PollStorageCapacity } from "./poll-storage-capacity"
import { BridgeVlanWorkflow } from "./bridge-vlan-workflow"
import { ObjectPermissionPreview, PolicyPreviewDecision } from "./object-permission-preview"
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame"
import { DataGrid, DataGridContainer, DataGridTable, DataGridScrollArea, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
import { Badge } from "@/components/reui/badge"
@@ -33,22 +35,56 @@ type Tab = keyof typeof tabs
type RecordRow = Record<string, unknown>
function pretty(value: unknown) { return JSON.stringify(value, null, 2) }
function date(value: unknown) { return value ? new Date(String(value)).toLocaleString("ru-RU") : "—" }
function connectionAccessDeadline(connection: McpConnection) {
return Math.min(Date.parse(connection.policy.expiresAt), Date.parse(connection.ownerValidUntil ?? ""))
}
function ConnectionAccessStatus({ connection }: { connection: McpConnection }) {
const [now, setNow] = useState(() => Date.now())
const expires = Date.parse(connection.policy.expiresAt), ownerExpires = Date.parse(connection.ownerValidUntil ?? "")
useEffect(() => {
let timer: number | undefined
const tick = () => {
const current = Date.now(); setNow(current)
const next = [expires, ownerExpires].filter(value => Number.isFinite(value) && value > current).reduce((first, value) => Math.min(first, value), Infinity)
if (Number.isFinite(next)) timer = window.setTimeout(tick, Math.min(60000, Math.max(1, next - current + 1)))
}
tick()
return () => window.clearTimeout(timer)
}, [expires, ownerExpires])
const [label, reason] = connection.policy.status === "revoked" ? ["Доступ отозван", "Подключение и его токены отозваны"]
: connection.policy.status === "paused" ? ["На паузе", "Новые вызовы подключения остановлены"]
: !connection.generationValid ? ["Доступ аннулирован", "Поколение доступа больше не действительно"]
: !Number.isFinite(expires) ? ["Срок не подтверждён", "Не удалось определить срок доступа"]
: expires <= now ? ["Срок доступа истёк", "Продлите срок в разрешениях подключения"]
: !Number.isFinite(ownerExpires) ? ["Права не подтверждены", "Срок подтверждения прав владельца отсутствует"]
: ownerExpires <= now ? ["Права владельца истекли", "Владельцу нужно подтвердить актуальные права"]
: ["Активен", "Срок доступа и права владельца действуют"]
return <div className="flex flex-col gap-1"><Badge variant={label === "Активен" ? "success-light" : "warning-light"}>{label}</Badge><span className="text-xs text-muted-foreground">{reason}</span></div>
}
function useQuery<T>(load: (signal: AbortSignal) => Promise<T>) {
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
let alive = true, revision = 0, pending = false, controller: AbortController | undefined, deadline: number | undefined
const run = async (replace = false) => {
if (!alive || document.hidden || (pending && !replace)) return
controller?.abort(); window.clearTimeout(deadline); controller = new AbortController(); const requestRevision = ++revision
pending = true
deadline = window.setTimeout(() => {
if (!alive || requestRevision !== revision) return
revision++; pending = false; controller?.abort()
setError("Обновление не завершилось за минуту. Следующая попытка выполнится автоматически.")
}, 60000)
try { const result = await load(controller.signal); if (alive && requestRevision === revision) { setData(result); setError(""); setUpdatedAt(new Date().toISOString()) } }
catch (failure) { if (alive && requestRevision === revision && !controller.signal.aborted) setError(errorText(failure)) }
finally { if (requestRevision === revision) { window.clearTimeout(deadline); pending = false } }
}
queueMicrotask(() => { void run() })
const tick = () => { void run() }, timer = window.setInterval(tick, 5000)
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) }
const reload = () => { void run(true) }
window.addEventListener("mcp-refresh", reload); document.addEventListener("visibilitychange", tick)
return () => { alive = false; revision++; controller?.abort(); window.clearTimeout(deadline); window.clearInterval(timer); window.removeEventListener("mcp-refresh", reload); document.removeEventListener("visibilitychange", tick) }
}, [load])
return { data, error, refresh, updatedAt }
}
@@ -75,7 +111,7 @@ function DeviceCatalog({ api, connectionId }: { api: McpApi; connectionId: strin
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} />}</>
return <><ErrorMessage message={catalog.error} /><div className="grid gap-3 sm:grid-cols-2"><NativeSelect label="Сервер интерфейсов" value={serverId} onChange={setServerId}><option value="">Выберите сервер</option>{catalog.data?.servers.map(server => <option key={server.id} value={String(server.id)} disabled={!server.enabled}>{server.name} · {server.host}</option>)}</NativeSelect><NativeSelect label="Тип объектов устройства" value={resourceId} onChange={setResourceId}><option value="">Выберите тип</option>{catalog.data?.resources.filter(resource => resource.read).map(resource => <option key={resource.id} value={resource.id}>{resource.title}</option>)}</NativeSelect></div>{catalog.data && !catalog.data.resources.some(resource => resource.read) && <p className="text-sm text-muted-foreground">Подключению не выданы инструменты чтения интерфейсов.</p>}{resourceId === "bridge" && serverId && catalog.data?.servers.some(server => String(server.id) === serverId && server.enabled) && <BridgeVlanWorkflow key={`vlan:${connectionId}:${serverId}`} api={api} connectionId={connectionId} serverId={serverId} />}{resource && serverId && catalog.data?.servers.some(server => String(server.id) === serverId && server.enabled) && <DeviceEditor key={`${serverId}:${resourceId}`} api={api} connectionId={connectionId} serverId={serverId} resource={resource} />}</>
}
function DeviceEditor({ api, connectionId, serverId, resource }: { api: McpApi; connectionId: string; serverId: string; resource: DeviceResource }) {
const [offset, setOffset] = useState(0), [editing, setEditing] = useState<RecordRow | "new" | null>(null), [draft, setDraft] = useState<Record<string, unknown>>({})
@@ -90,12 +126,18 @@ function DeviceEditor({ api, connectionId, serverId, resource }: { api: McpApi;
} catch (failure) { setError(errorText(failure)) } finally { setBusy(false) }
}
const current = editing && editing !== "new" ? editing : null
const ethernetDisabledUnknown = resource.id === "ethernet" && current !== null && current.disabled === undefined
const editableFields = resource.fields.filter(field =>
editing === "new" || field.key === "comment" || field.key in (current ?? {}) || (ethernetDisabledUnknown && field.key === "disabled"),
)
const protectedRow = current && ["dynamic", "builtin", "default"].some(key => current[key] === "true" || current[key] === true)
const change = (key: string, value: unknown) => setDraft(fields => { const next = { ...fields }; if (value === undefined) delete next[key]; else next[key] = value; return next })
return <><ErrorMessage message={rows.error || error} />{receipt && <p role="status" className="text-sm">{receipt}</p>}<div className="flex flex-wrap items-center justify-between gap-2"><p className="text-sm text-muted-foreground">Объектов: {rows.data?.total ?? "…"}. Выберите строку для просмотра и подготовки изменения.</p>{resource.create && <Button variant="outline" disabled={busy} onClick={() => open("new")}><PlusIcon />Создать объект</Button>}</div><Grid items={rows.data?.items ?? []} loading={!rows.data && !rows.error} total={rows.data?.total} columns={[{ id: "nativeId", header: "ID", cell: ({ row }) => String(row.original[".id"] ?? "—") }, { id: "name", header: "Объект", cell: ({ row }) => String(row.original.name ?? row.original.address ?? row.original.interface ?? "—") }, { id: "state", header: "Состояние", cell: ({ row }) => String(row.original.disabled === "true" || row.original.disabled === true ? "Выключен" : row.original.running ?? "—") }]} onSelect={row => { if (row[".id"] && !busy) open(row) }} /><div className="flex gap-2"><Button variant="outline" disabled={busy || offset === 0} onClick={() => { setOffset(Math.max(0, offset - 100)); setEditing(null) }}>Предыдущие объекты</Button><Button variant="outline" disabled={busy || rows.data?.nextOffset == null} onClick={() => { if (rows.data?.nextOffset != null) setOffset(rows.data.nextOffset); setEditing(null) }}>Следующие объекты</Button></div>{editing && <div className="flex flex-col gap-4 rounded-md border p-4"><p className="font-medium">{editing === "new" ? "Новый объект" : `Объект ${String(current?.[".id"])}`}</p>{current && <details><summary className="cursor-pointer text-sm">Текущие свойства</summary><pre className="max-h-60 overflow-auto whitespace-pre-wrap break-all text-xs">{pretty(current)}</pre></details>}{protectedRow ? <p className="text-sm">Системный или динамический объект доступен для чтения.</p> : <><p className="text-xs text-muted-foreground">В план попадут только заполненные изменения. Пустой выбор оставляет свойство без изменения; при создании действует настройка устройства.</p><div className="grid gap-3 sm:grid-cols-2">{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>}</>
return <><ErrorMessage message={rows.error || error} />{receipt && <p role="status" className="text-sm">{receipt}</p>}<div className="flex flex-wrap items-center justify-between gap-2"><p className="text-sm text-muted-foreground">Объектов: {rows.data?.total ?? "…"}. Выберите строку для просмотра и подготовки изменения.</p>{resource.create && <Button variant="outline" disabled={busy} onClick={() => open("new")}><PlusIcon />Создать объект</Button>}</div><Grid items={rows.data?.items ?? []} loading={!rows.data && !rows.error} total={rows.data?.total} columns={[{ id: "nativeId", header: "ID", cell: ({ row }) => String(row.original[".id"] ?? "—") }, { id: "name", header: "Объект", cell: ({ row }) => String(row.original.name ?? row.original.address ?? row.original.interface ?? "—") }, { id: "state", header: "Состояние", cell: ({ row }) => String(row.original.disabled === "true" || row.original.disabled === true ? "Выключен" : row.original.running ?? "—") }]} onSelect={row => { if (row[".id"] && !busy) open(row) }} /><div className="flex gap-2"><Button variant="outline" disabled={busy || offset === 0} onClick={() => { setOffset(Math.max(0, offset - 100)); setEditing(null) }}>Предыдущие объекты</Button><Button variant="outline" disabled={busy || rows.data?.nextOffset == null} onClick={() => { if (rows.data?.nextOffset != null) setOffset(rows.data.nextOffset); setEditing(null) }}>Следующие объекты</Button></div>{editing && <div className="flex flex-col gap-4 rounded-md border p-4"><p className="font-medium">{editing === "new" ? "Новый объект" : `Объект ${String(current?.[".id"])}`}</p>{current && <details><summary className="cursor-pointer text-sm">Текущие свойства</summary><pre className="max-h-60 overflow-auto whitespace-pre-wrap break-all text-xs">{pretty(current)}</pre></details>}{protectedRow ? <p className="text-sm">Системный или динамический объект доступен для чтения.</p> : <><p className="text-xs text-muted-foreground">В план попадут только заполненные изменения. Пустой выбор оставляет свойство без изменения; при создании действует настройка устройства.</p><div className="grid gap-3 sm:grid-cols-2">{editableFields.map(field => <Field key={field.key} label={`${field.key}${editing === "new" && field.requiredOnCreate ? " *" : ""}`}>{field.type === "boolean" || field.options ? <select aria-label={field.key} className="h-9 min-w-0 rounded-md border border-input bg-background px-2 text-sm" value={String(draft[field.key] ?? "")} disabled={busy || editing !== "new" && !resource.update} onChange={event => change(field.key, event.target.value === "" ? undefined : field.type === "boolean" ? event.target.value === "true" : event.target.value)}><option value="">{ethernetDisabledUnknown && field.key === "disabled" ? "Текущее значение неизвестно" : current ? `Сейчас: ${String(current[field.key])}` : "По умолчанию устройства"}</option>{(field.options ?? ["true", "false"]).map(value => <option key={value} value={value}>{value}</option>)}</select> : <Input aria-label={field.key} type={field.type === "number" ? "number" : "text"} min={field.min} max={field.max} maxLength={field.maxLength} disabled={busy || editing !== "new" && !resource.update} placeholder={current ? String(current[field.key] ?? "") : ""} value={String(draft[field.key] ?? "")} onChange={event => change(field.key, field.type === "number" ? event.target.value === "" ? undefined : Number(event.target.value) : event.target.value)} />}</Field>)}</div>
{ethernetDisabledUnknown && draft.disabled !== undefined && <p className="text-sm text-muted-foreground">Исходное состояние неизвестно. Для этого изменения обратный план недоступен.</p>}
</>}<div className="flex flex-wrap gap-2">{!protectedRow && (editing === "new" ? resource.create : resource.update) && <Button disabled={busy || !Object.keys(draft).length} onClick={() => { void prepare(editing === "new" ? "create" : "update") }}>Подготовить план изменения</Button>}{current && !protectedRow && resource.delete && <Button variant="destructive" disabled={busy} onClick={() => { void prepare("delete") }}>Подготовить план удаления</Button>}<Button variant="outline" disabled={busy} onClick={() => setEditing(null)}>Закрыть объект</Button></div></div>}</>
}
function TokenDialog({ token, close }: { 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 TokenDialog({ token, close, connectionName }: { token: string; close: () => void; connectionName?: string }) {
return <Dialog open={!!token} onOpenChange={open => { if (!open) close() }}><DialogContent className="sm:max-w-xl"><DialogHeader><DialogTitle>Доступ создан</DialogTitle><DialogDescription>{connectionName && `Подключение «${connectionName}». `}Сохраните токен в настройках AI-клиента. Повторно посмотреть его нельзя.</DialogDescription></DialogHeader><Input aria-label="Одноразовый токен MCP" readOnly type="password" value={token} /><Button onClick={() => { void navigator.clipboard.writeText(token).then(() => toast.success("Токен скопирован"), () => toast.error("Не удалось скопировать токен")) }}><CopyIcon />Копировать</Button><Button variant="outline" onClick={close}>Закрыть и скрыть токен</Button></DialogContent></Dialog>
}
function ConnectionForm({ api, operations, editing, close, created }: { api: McpApi; operations: McpOperationDescriptor[]; editing?: McpConnection; close: () => void; created: (token?: string) => void }) {
const [step, setStep] = useState(1), [busy, setBusy] = useState(false), [error, setError] = useState("")
@@ -133,28 +175,110 @@ function Connections({ api, operations }: { api: McpApi; operations: McpOperatio
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> },
{ id: "status", header: "Состояние доступа", size: 260, cell: ({ row }) => <ConnectionAccessStatus connection={row.original as unknown as McpConnection} /> },
{ id: "expiresAt", header: "Доступ до", size: 170, cell: ({ row }) => date((row.original.policy as McpConnection["policy"]).expiresAt) },
{ accessorKey: "lastUsedAt", header: "Последний вызов", cell: ({ row }) => date(row.original.lastUsedAt) },
{ id: "ownerValidation", header: "Права владельца", size: 190, cell: ({ row }) => { const connection = row.original as unknown as McpConnection; return <div className="flex flex-col gap-1"><span className="text-xs">До {date(connection.ownerValidUntil)}</span>{connection.ownerId === getClaims()?.sub && <Button size="sm" variant="outline" disabled={!connection.generationValid || connection.policy.status === "revoked"} onClick={() => { void act(() => api.revalidate(connection.id)) }}>Обновить мои права</Button>}</div> } },
{ id: "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)) }
const [selected, setSelected] = useState("")
const [grantBusy, setGrantBusy] = useState(false)
const [issued, setIssued] = useState<{ token: string; connectionName: string } | null>(null)
const grantLocked = useRef(false)
const connection = connections.data?.connections.find(value => value.id === selected)
const releaseGrant = () => {
grantLocked.current = false
setGrantBusy(false)
}
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>
const issue = async (grant: Parameters<McpApi["grant"]>[1]) => {
if (!connection || grantLocked.current) return
grantLocked.current = true
setGrantBusy(true)
try {
const { token } = await api.grant(connection.id, grant)
setIssued({ token, connectionName: connection.name })
connections.refresh()
} catch (error) {
releaseGrant()
toast.error(errorText(error))
}
}
return <Panel title="Проверка подключения и временные допуски" description="Допуск с отдельным токеном может только сузить основной доступ и действует не более 24 часов">
<ErrorMessage message={connections.error} />
<NativeSelect label="Подключение для проверки" value={selected} onChange={setSelected}>
<option value="">Выберите подключение</option>
{connections.data?.connections.map(connection => <option key={connection.id} value={connection.id}>{connection.name}</option>)}
</NativeSelect>
{connection && <ConnectionAccessTools key={connection.id} api={api} connection={connection} issue={issue} grantBusy={grantBusy} />}
{issued && <TokenDialog token={issued.token} connectionName={issued.connectionName} close={() => { setIssued(null); releaseGrant() }} />}
</Panel>
}
function ConnectionAccessTools({ api, connection, issue, grantBusy }: {
api: McpApi
connection: McpConnection
issue: (grant: Parameters<McpApi["grant"]>[1]) => Promise<void>
grantBusy: boolean
}) {
const [result, setResult] = useState<Awaited<ReturnType<McpApi["check"]>> | null>(null)
const [operations, setOperations] = useState<string[]>([]), [serverText, setServerText] = useState(""), [minutes, setMinutes] = useState(60), [global, setGlobal] = useState(false)
const connectionId = connection.id
const grants = useQuery(useCallback(signal => api.grants(connectionId, signal), [api, connectionId]))
const checkRevision = useRef(0)
useEffect(() => () => { checkRevision.current++ }, [])
const check = async () => {
const revision = ++checkRevision.current
setResult(null)
try {
const readiness = await api.check(connectionId)
if (revision === checkRevision.current) setResult(readiness)
} catch (error) {
if (revision === checkRevision.current) toast.error(errorText(error))
}
}
const create = () => {
const now = new Date(), expires = new Date(Math.min(now.getTime() + minutes * 60000, Date.parse(connection.policy.expiresAt)))
try {
void issue({
id: crypto.randomUUID(), startsAt: now.toISOString(), expiresAt: expires.toISOString(),
scope: { serverIds: serverText.split(",").map(value => value.trim()).filter(Boolean).map(Number), operationIds: operations, allowGlobal: global },
})
} catch (error) {
toast.error(errorText(error))
}
}
return <>
<ErrorMessage message={grants.error} />
<p className="text-sm text-muted-foreground">Права владельца подтверждены до {date(connection.ownerValidUntil)}. Для продления войдите с актуальными правами портала; старые планы и допуски контроллера потребуется подготовить заново.</p>
<Button variant="outline" className="self-start" disabled={connection.ownerId !== getClaims()?.sub} onClick={() => { void api.revalidate(connectionId).then(() => { grants.refresh(); toast.success("Права владельца обновлены") }, error => toast.error(errorText(error))) }}>Подтвердить текущие права владельца</Button>
<Button variant="outline" className="self-start" onClick={() => { void check() }}>Проверить готовность доступа</Button>
{result !== null && <>
{result.protocolProbe?.capabilitiesRead && <p role="status" className="text-sm text-muted-foreground">
{({ passed: "Проверочное чтение возможностей прошло.", failed: "Проверочное чтение возможностей завершилось ошибкой.", skipped: "Проверочное чтение возможностей пропущено." })[result.protocolProbe.capabilitiesRead]}
</p>}
<pre className="overflow-auto rounded bg-muted p-3 text-xs">{pretty(result)}</pre>
</>}
<div className="grid gap-3 sm:grid-cols-2">
<Field label={`Серверы допуска (доступны: ${connection.policy.scope.serverIds.join(", ")})`}><Input value={serverText} onChange={event => setServerText(event.target.value)} placeholder="ID через запятую" /></Field>
<Field label="Срок допуска, минут"><Input type="number" min={1} max={1440} value={minutes} onChange={event => setMinutes(Number(event.target.value))} /></Field>
</div>
{connection.policy.scope.allowGlobal && <Choice label="Разрешить глобальные действия в допуске" checked={global} onChange={setGlobal} />}
<div className="grid max-h-60 gap-2 overflow-y-auto sm:grid-cols-2">{connection.policy.scope.operationIds.map(id => <Choice key={id} label={id} checked={operations.includes(id)} onChange={checked => setOperations(values => checked ? [...values, id] : values.filter(value => value !== id))} />)}</div>
<Button className="self-start" disabled={grantBusy || !operations.length || connection.policy.status !== "active"} onClick={create}>Выдать временный допуск</Button>
{grants.data?.grants.map(grant => <div key={grant.id} className="flex flex-wrap items-center justify-between gap-2 rounded border p-3 text-sm">
<span>До {date(grant.grant.expiresAt)} · {grant.revoked_at ? "отозван" : "выдан"}</span>
<Button size="sm" variant="outline" disabled={!!grant.revoked_at} onClick={() => { void api.revokeGrant(grant.id).then(grants.refresh, error => toast.error(errorText(error))) }}>Отозвать допуск</Button>
</div>)}
</>
}
function RoutingProfileForm({ api, editing, close }: { api: McpApi; editing?: RecordRow; close: () => void }) {
const connections = useQuery(useCallback(signal => api.connections(signal), [api]))
@@ -197,20 +321,37 @@ function CandidateProbeEditor({ candidates, update }: { candidates: CandidateFor
}
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 [profileId, setProfileId] = useState(""), [candidateId, setCandidateId] = useState(""), [busy, setBusy] = useState(false)
const [proofPurpose, setProofPurpose] = useState<"community" | "control-path">("community")
const [feedback, setFeedback] = useState<{ key: string; result: unknown } | { key: string; error: string } | null>(null)
const revision = useRef(0)
const profile = profiles.data?.profiles.find(profile => profile.id === profileId), config = routeAiProfileSchema.safeParse(profile?.config)
const check = async () => { setBusy(true); setError(""); setResult(null); try { setResult(await api.checkRoutePath(profileId, candidateId, proofPurpose)) } catch (failure) { setError(errorText(failure)) } finally { setBusy(false) } }
const candidates = config.success ? config.data.candidates.filter(candidate => candidate.controlProbe) : []
const canCheck = candidates.some(candidate => candidate.id === candidateId)
const selectionKey = JSON.stringify([profileId, profile?.version, candidateId, proofPurpose])
const currentFeedback = feedback?.key === selectionKey ? feedback : null
const invalidate = () => { revision.current++; setFeedback(null) }
const check = async () => {
if (busy || !canCheck) return
const request = ++revision.current
setBusy(true); setFeedback(null)
try {
const result = await api.checkRoutePath(profileId, candidateId, proofPurpose)
if (request === revision.current) setFeedback({ key: selectionKey, result })
} catch (failure) {
if (request === revision.current) setFeedback({ key: selectionKey, error: errorText(failure) })
} finally { setBusy(false) }
}
return <Panel title="Контрольная проверка пути" description="Ограниченные ping и traceroute; неизвестный результат не считается отказом пути.">
<ErrorMessage message={profiles.error || error} />
<ErrorMessage message={profiles.error || (currentFeedback && "error" in currentFeedback ? currentFeedback.error : "")} />
<div className="grid gap-3 sm:grid-cols-2">
<NativeSelect label="Профиль для проверки пути" value={profileId} onChange={value => { 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>
<NativeSelect label="Профиль для проверки пути" value={profileId} onChange={value => { invalidate(); setProfileId(value); setCandidateId("") }}><option value="">Выберите профиль</option>{profiles.data?.profiles.map(profile => <option key={String(profile.id)} value={String(profile.id)}>{String((profile.config as RecordRow)?.name ?? profile.id)}</option>)}</NativeSelect>
<NativeSelect label="Проверяемый путь" value={candidateId} onChange={value => { invalidate(); setCandidateId(value) }}><option value="">Выберите путь</option>{candidates.map(candidate => <option key={candidate.id} value={candidate.id}>{candidate.id}</option>)}</NativeSelect>
<NativeSelect label="Назначение проверки" value={proofPurpose} onChange={value => { invalidate(); setProofPurpose(value as typeof proofPurpose) }}><option value="community">Путь Community</option><option value="control-path">Самостоятельный контрольный путь</option></NativeSelect>
</div>
<p className="text-sm text-muted-foreground">{proofPurpose === "control-path" ? "Проверяется только контрольный путь. Результат не разрешает переключение Community и не подтверждает обратный путь." : "Проверка учитывает Community, таблицу маршрутизации и BGP. Применение изменений требует отдельного плана; обратный путь проверяется отдельно."}</p>
<Button disabled={busy || !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>}
<Button disabled={busy || !canCheck} onClick={() => { void check() }}>Проверить контрольный путь</Button>
{currentFeedback && "result" in currentFeedback && <pre role="status" className="max-h-72 overflow-auto whitespace-pre-wrap break-all text-xs">{pretty(currentFeedback.result)}</pre>}
</Panel>
}
function RoutingProfiles({ api }: { api: McpApi }) {
@@ -248,21 +389,121 @@ function Records({ api, kind }: { api: McpApi; kind: "requests" | "plans" | "ope
{ 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>
const pendingPlanRequests = useRef(new Set<string>())
const [pendingPlans, setPendingPlans] = useState<ReadonlySet<string>>(() => new Set())
const selectedPlanPending = kind === "plans" && !!selected && pendingPlans.has(String(selected.id))
const submitPlan = async (action: "approve" | "reject" | "apply") => {
if (!selected) return
const plan = selected
const planId = String(plan.id)
const pending = pendingPlanRequests.current
if (pending.has(planId)) return
pending.add(planId)
setPendingPlans(new Set(pending))
try {
if (action === "apply") {
await api.apply(planId)
toast.success("Операция принята; результат появится в списке операций")
} else {
await api.decision(planId, action === "approve")
}
setSelected(current => current === plan ? null : current)
query.refresh()
} catch (error) {
toast.error(errorText(error))
} finally {
pending.delete(planId)
setPendingPlans(new Set(pending))
}
}
return <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 disabled={selectedPlanPending} onClick={() => { void submitPlan("apply") }}>Применить план</Button>}{kind === "plans" && selected?.state === "awaiting-approval" && can("mm:mcp:approve") && <div className="flex gap-2"><Button disabled={selectedPlanPending} onClick={() => { void submitPlan("approve") }}>Подтвердить план</Button><Button variant="outline" disabled={selectedPlanPending} onClick={() => { void submitPlan("reject") }}>Отклонить</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("")
function ConnectionPolicySimulation({ api, connection, operations }: {
api: McpApi
connection: McpConnection
operations: McpOperationDescriptor[]
}) {
const [operationId, setOperationId] = useState(""), [serverText, setServerText] = useState("")
const [result, setResult] = useState<McpPolicyDecision | null>(null)
const revision = useRef(0)
useEffect(() => () => { revision.current++ }, [])
const available = operations.filter(operation => connection.policy.scope.operationIds.includes(operation.id))
const operation = available.find(operation => operation.id === operationId)
const target = mcpTargetSchema.safeParse(operation?.scope === "servers"
? { scope: "servers", serverIds: serverText.split(/[,\s]+/).filter(Boolean).map(value => /^\d+$/.test(value) ? Number(value) : NaN) }
: { scope: operation?.scope })
const invalidate = () => { revision.current++; setResult(null) }
const preview = async () => {
if (!operation || !target.success) return
const current = ++revision.current
setResult(null)
try {
const response = await api.preview({ connectionId: connection.id, operationId: operation.id, target: target.data })
if (revision.current === current) setResult(response.result)
} catch (error) {
if (revision.current === current) toast.error(errorText(error))
}
}
return <>
<div className="grid gap-3 md:grid-cols-2">
<NativeSelect label="Операция" value={operationId} onChange={value => { invalidate(); setOperationId(value); setServerText("") }}>
<option value="">Выберите действие</option>
{available.map(operation => <option key={operation.id} value={operation.id}>{operation.title} · {operation.id}</option>)}
</NativeSelect>
{operation?.scope === "servers" && <Field label="ID серверов"><Input value={serverText} onChange={event => { invalidate(); setServerText(event.target.value) }} /></Field>}
</div>
{operation && <p className="text-sm text-muted-foreground">{{ connection: "Область операции — подключение", global: "Область операции — всё приложение", servers: "Область операции — выбранные серверы" }[operation.scope]}</p>}
<Button className="self-start" disabled={!target.success || !can("mm:mcp:admin")} onClick={() => { void preview() }}><ShieldIcon />Проверить</Button>
{result && <PolicyPreviewDecision result={result} />}
</>
}
function Permissions({ api, operations }: { api: McpApi; operations: McpOperationDescriptor[] }) {
const connections = useQuery(useCallback(signal => api.connections(signal), [api]))
const protections = useQuery(useCallback(signal => api.protections(signal), [api]))
const [connectionId, setConnectionId] = useState("")
const [kind, setKind] = useState("server"), [objectId, setObjectId] = useState("")
const [protection, setProtection] = useState("human-approval-required"), [reason, setReason] = useState("")
const [protectionServerText, setProtectionServerText] = useState("")
const selected = connections.data?.connections.find(connection => connection.id === connectionId)
const 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>
const protectionItems = protections.data?.protections ?? []
const protectionServerId = Number(protectionServerText)
const validProtectionServer = /^\d+$/.test(protectionServerText) && Number.isSafeInteger(protectionServerId) && protectionServerId > 0
const addProtection = () => {
void api.protect({ kind, id: objectId, protection, ...(kind === "server" ? {} : { serverId: protectionServerId }) }, reason)
.then(protections.refresh, error => toast.error(errorText(error)))
}
return <div className="flex flex-col gap-4">
<Panel title="Проверка разрешений" description="Симуляция текущей политики подключения без выполнения операции. Защиту конкретного объекта можно проверить отдельно.">
<ErrorMessage message={connections.error || protections.error} />
<NativeSelect label="Подключение" value={connectionId} onChange={setConnectionId}>
<option value="">Выберите подключение</option>
{connections.data?.connections.map(connection => <option key={connection.id} value={connection.id}>{connection.name}</option>)}
</NativeSelect>
{selected && <ConnectionPolicySimulation key={`simulation:${selected.id}:${selected.policy.version}`} api={api} connection={selected} operations={operations} />}
{selected && selected.ownerId === getClaims()?.sub && <ObjectPermissionPreview key={`object:${selected.id}:${selected.policy.version}`} api={api} connection={selected} operations={operations} editable={can("mm:mcp:admin") && !protections.error && !!protections.data} onProtected={protections.refresh} />}
</Panel>
<Panel title="Защищённые объекты" description="Защита сохраняется и при автономном доступе">
<div className="flex flex-col gap-2">{protectionItems.map(item => <div key={item.id} className="flex flex-wrap items-center justify-between gap-2 rounded border p-3 text-sm"><span>{item.object_ref.kind} · {item.object_ref.id} · {item.object_ref.protection}<span className="ml-2 text-muted-foreground">{item.reason}</span></span>{can("mm:mcp:admin") && <Button variant="outline" size="sm" onClick={() => { if (window.confirm("Удалить защиту объекта?")) void api.removeProtection(item.id).then(protections.refresh, error => toast.error(errorText(error))) }}>Удалить защиту</Button>}</div>)}</div>
{can("mm:mcp:admin") && <>
<div className="grid gap-3 md:grid-cols-4">
<Field label="Тип объекта"><Input value={kind} onChange={event => setKind(event.target.value)} /></Field>
<Field label="Стабильный ID"><Input value={objectId} onChange={event => setObjectId(event.target.value)} /></Field>
{kind !== "server" && <Field label="ID сервера защищаемого объекта"><Input value={protectionServerText} onChange={event => setProtectionServerText(event.target.value)} /></Field>}
<NativeSelect label="Защита" value={protection} onChange={setProtection}><option value="human-approval-required">Подтверждение человеком</option><option value="deny">Запрет изменений</option></NativeSelect>
<Field label="Причина"><Input value={reason} onChange={event => setReason(event.target.value)} /></Field>
</div>
<Button className="self-start" disabled={!objectId || kind !== "server" && !validProtectionServer} onClick={addProtection}>Добавить защиту</Button>
</>}
</Panel>
</div>
}
function OperationRecovery({ api }: { api: McpApi }) {
const [id, setId] = useState(""), [result, setResult] = useState<unknown>(null), [error, setError] = useState(""), [busy, setBusy] = useState(false)
@@ -304,15 +545,29 @@ function Consent({ api, id }: { api: McpApi; id: string }) {
}, [])
const currentRequest = !request.error && request.data?.id === id ? request.data : null
const ownerId = getClaims()?.sub
const eligible = connections.error ? [] : connections.data?.connections.filter(connection => connection.ownerId === ownerId && connection.policy.status === "active") ?? []
const canSubmit = currentRequest !== null && eligible.some(connection => connection.id === selected)
const [now, setNow] = useState(() => Date.now())
const active = connections.error ? [] : connections.data?.connections.filter(connection => connection.ownerId === ownerId && connection.policy.status === "active" && connection.generationValid) ?? []
const nextExpiry = active.reduce((earliest, connection) => {
const deadline = connectionAccessDeadline(connection)
return deadline > now ? Math.min(earliest, deadline) : earliest
}, Infinity)
useEffect(() => {
if (!Number.isFinite(nextExpiry)) return
const delay = Math.max(0, nextExpiry - Date.now() + 1)
const timer = window.setTimeout(() => setNow(Date.now()), Math.min(delay, 60000))
return () => window.clearTimeout(timer)
}, [nextExpiry, now])
const eligible = active.filter(connection => connectionAccessDeadline(connection) > now)
const canDeny = currentRequest !== null
const canApprove = canDeny && eligible.some(connection => connection.id === selected)
const isCurrentLocation = () => mounted.current && new URLSearchParams(window.location.search).get("oauthRequest") === id
const confirm = async (approve: boolean) => {
if (!canSubmit || submitting.current || !isCurrentLocation()) return
if (!(approve ? canApprove : canDeny) || submitting.current || !isCurrentLocation()) return
if (approve && !eligible.some(connection => connection.id === selected && connectionAccessDeadline(connection) > Date.now())) return
submitting.current = true
setBusy(true)
try {
const result = await api.consent(id, selected, approve)
const result = await api.consent(id, approve ? { approve: true, connectionId: selected } : { approve: false })
if (isCurrentLocation()) window.location.assign(result.redirectUrl)
} catch (error) {
if (!mounted.current) return
@@ -329,8 +584,8 @@ function Consent({ api, id }: { api: McpApi; id: string }) {
{eligible.map(connection => <option key={connection.id} value={connection.id}>{connection.name} · {modeLabels[connection.policy.mode]}</option>)}
</NativeSelect>
<div className="flex gap-2">
<Button disabled={!canSubmit || busy} onClick={() => { void confirm(true) }}>Разрешить</Button>
<Button variant="outline" disabled={!canSubmit || busy} onClick={() => { void confirm(false) }}>Отклонить</Button>
<Button disabled={!canApprove || busy} onClick={() => { void confirm(true) }}>Разрешить</Button>
<Button variant="outline" disabled={!canDeny || busy} onClick={() => { void confirm(false) }}>Отклонить</Button>
</div>
</Panel>
}
@@ -346,5 +601,5 @@ function McpWorkspaceContent({ backendUrl, live }: { backendUrl: string; live: b
const [operations, setOperations] = useState<McpOperationDescriptor[]>([]), [error, setError] = useState("")
useEffect(() => { if (!live) return; const controller = new AbortController(); void api.catalog(controller.signal).then(catalog => setOperations(catalog.operations), failure => { if (!controller.signal.aborted) setError(errorText(failure)) }); return () => controller.abort() }, [api, live])
const changeTab = (value: unknown) => { const next = String(value) as Tab; setTab(next); const url = new URL(window.location.href); url.searchParams.set("tab", next); window.history.replaceState(null, "", url.pathname + url.search) }
return <><PageHeader crumbs={[{ label: "MCP" }]} /><main className="flex min-w-0 flex-1 flex-col gap-5 p-4 md:p-6"><div className="flex items-center gap-3"><BotIcon className="size-7 text-muted-foreground" /><div><h1 className="text-xl font-semibold">MCP</h1><p className="text-sm text-muted-foreground">Доступ нейросетей, разрешения и контроль изменений</p></div></div>{!live ? <Panel title="Подключите backend"><p>Для выдачи реального AI-доступа выберите режим «Живые» и настройте авторизацию портала.</p></Panel> : <><ErrorMessage message={error} />{oauthRequestId && <Consent key={oauthRequestId} api={api} id={oauthRequestId} />}<Tabs value={tab} onValueChange={changeTab}><div className="overflow-x-auto pb-1"><TabsList variant="line">{Object.entries(tabs).filter(([key]) => key !== "settings" || can("mm:mcp:admin")).map(([key, title]) => <TabsTrigger key={key} value={key}>{title}</TabsTrigger>)}</TabsList></div><TabsContent value="overview"><Overview api={api} /></TabsContent><TabsContent value="connections"><div className="flex flex-col gap-4"><Connections api={api} operations={operations} />{can("mm:mcp:admin") && <AccessTools api={api} />}</div></TabsContent><TabsContent value="permissions"><Permissions api={api} /></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></>
return <><PageHeader crumbs={[{ label: "MCP" }]} /><main className="flex min-w-0 flex-1 flex-col gap-5 p-4 md:p-6"><div className="flex items-center gap-3"><BotIcon className="size-7 text-muted-foreground" /><div><h1 className="text-xl font-semibold">MCP</h1><p className="text-sm text-muted-foreground">Доступ нейросетей, разрешения и контроль изменений</p></div></div>{!live ? <Panel title="Подключите backend"><p>Для выдачи реального AI-доступа выберите режим «Живые» и настройте авторизацию портала.</p></Panel> : <><ErrorMessage message={error} />{oauthRequestId && <Consent key={oauthRequestId} api={api} id={oauthRequestId} />}<Tabs value={tab} onValueChange={changeTab}><div className="overflow-x-auto pb-1"><TabsList variant="line">{Object.entries(tabs).filter(([key]) => key !== "settings" || can("mm:mcp:admin")).map(([key, title]) => <TabsTrigger key={key} value={key}>{title}</TabsTrigger>)}</TabsList></div><TabsContent value="overview"><Overview api={api} /></TabsContent><TabsContent value="connections"><div className="flex flex-col gap-4"><Connections api={api} operations={operations} />{can("mm:mcp:admin") && <AccessTools api={api} />}</div></TabsContent><TabsContent value="permissions"><Permissions api={api} operations={operations} /></TabsContent><TabsContent value="devices"><DeviceManagement api={api} /></TabsContent><TabsContent value="routing"><div className="flex flex-col gap-4"><RoutingProfiles api={api} /><RoutePathVerification api={api} /></div></TabsContent><TabsContent value="requests"><Records api={api} kind="requests" /></TabsContent><TabsContent value="plans"><div className="flex flex-col gap-4"><Records api={api} kind="plans" /><Records api={api} kind="operations" /><OperationRecovery 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></>
}