fix(mcp): довести базовые сценарии и проверку маршрутизации
This commit is contained in:
1 parent
1f0704add0
commit
04225886e0
101 files changed
+6118
-329
No files matched your search
@@ -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></>
|
||||
}
|
||||
Reference in new issue
Block a user