feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,36 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { humanArtifactError, humanArtifactReceipt } from "@/shared/api/mcp-artifact"
|
||||
import type { McpApi } from "@/shared/api/mcp"
|
||||
|
||||
function ArtifactDownload({ api, receipt }: { api: McpApi; receipt: unknown }) {
|
||||
const [busy, setBusy] = useState(false), [error, setError] = useState(""), [saved, setSaved] = useState(false)
|
||||
const controller = useRef<AbortController | null>(null)
|
||||
useEffect(() => () => controller.current?.abort(), [])
|
||||
const download = async () => {
|
||||
if (controller.current) return
|
||||
const request = new AbortController()
|
||||
controller.current = request
|
||||
setBusy(true); setError(""); setSaved(false)
|
||||
try { await api.downloadArtifact(receipt, request.signal); if (!request.signal.aborted) setSaved(true) }
|
||||
catch (failure) { if (!request.signal.aborted) setError(humanArtifactError(failure)) }
|
||||
finally { if (!request.signal.aborted) setBusy(false); controller.current = null }
|
||||
}
|
||||
return <div className="flex flex-col items-start gap-2">
|
||||
<Button variant="outline" disabled={busy} aria-busy={busy} onClick={() => { void download() }}>{busy ? "Скачивание…" : "Скачать файл"}</Button>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">{busy ? "Получаем файл с проверкой доступа…" : saved ? "Файл передан браузеру для сохранения." : "Файл доступен владельцу через авторизацию портала. Срок хранения ограничен."}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function ArtifactDownloads({ api, operation }: { api: McpApi; operation: Record<string, unknown> }) {
|
||||
if (operation.state !== "applied" || !Array.isArray(operation.artifactReceipts)) return null
|
||||
const receipts = [...new Map(operation.artifactReceipts.flatMap(value => {
|
||||
const receipt = humanArtifactReceipt(value)
|
||||
return receipt ? [[receipt.artifactId, value] as const] : []
|
||||
})).entries()]
|
||||
if (!receipts.length) return null
|
||||
return <div className="flex flex-col gap-3 rounded-md border p-3"><h3 className="text-sm font-medium">Файлы операции</h3>{receipts.map(([id, receipt]) => <ArtifactDownload key={id} api={api} receipt={receipt} />)}</div>
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import type { McpApi } from "@/shared/api/mcp"
|
||||
|
||||
export function DatabaseRestore({ api }: { api: McpApi }) {
|
||||
const [connections, setConnections] = useState<{ id: string; name: string }[]>([])
|
||||
const [connectionId, setConnectionId] = useState("")
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [archive, setArchive] = useState<Awaited<ReturnType<McpApi["uploadRestoreArchive"]>> | null>(null)
|
||||
const [operationId, setOperationId] = useState("")
|
||||
const [result, setResult] = useState<unknown>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
useEffect(() => {
|
||||
const abort = new AbortController()
|
||||
void api.connections(abort.signal).then(data => setConnections(data.connections.filter(item => item.policy.scope.allowGlobal && item.policy.scope.operationIds.includes("database.restore"))), failure => { if (!abort.signal.aborted) setError(String(failure)) })
|
||||
return () => abort.abort()
|
||||
}, [api])
|
||||
const run = async (work: () => Promise<unknown>) => {
|
||||
setBusy(true); setError("")
|
||||
try { setResult(await work()) } catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)) } finally { setBusy(false) }
|
||||
}
|
||||
return <section className="flex flex-col gap-3 rounded-xl border p-4" aria-labelledby="mcp-database-restore-title">
|
||||
<h2 id="mcp-database-restore-title" className="font-semibold">Восстановление БД через MCP</h2>
|
||||
<p className="text-sm text-muted-foreground">Архив PostgreSQL загружает администратор. Модель получает ссылку и SHA-256. Выполнение требует отдельного подтверждения в планах. После восстановления прежний MCP-доступ отзывается, а результат хранится во внешней квитанции.</p>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
<label className="flex flex-col gap-1 text-sm">Подключение для восстановления
|
||||
<select className="h-9 rounded-md border bg-background px-3" value={connectionId} onChange={event => setConnectionId(event.target.value)}><option value="">Выберите свой глобальный допуск</option>{connections.map(item => <option key={item.id} value={item.id}>{item.name}</option>)}</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-sm">Архив PostgreSQL, до 64 МиБ
|
||||
<Input type="file" accept=".dump,.backup" onChange={event => { setFile(event.target.files?.[0] ?? null); setArchive(null); setResult(null) }} />
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" disabled={busy || !file || file.size > 64 * 1024 * 1024} onClick={() => { if (file) void run(async () => { const data = await api.uploadRestoreArchive(file); setArchive(data); return data }) }}>Загрузить архив</Button>
|
||||
<Button disabled={busy || !archive || !connectionId} onClick={() => { if (archive) void run(() => api.restorePlan(connectionId, archive.archiveRef, archive.sha256)) }}>Подготовить план восстановления БД</Button>
|
||||
</div>
|
||||
{archive && <p className="break-all text-xs text-muted-foreground">SHA-256: {archive.sha256} · Допуск до {new Date(archive.expiresAt).toLocaleString()}</p>}
|
||||
<label className="flex flex-col gap-1 text-sm">ID операции восстановления БД<Input value={operationId} onChange={event => setOperationId(event.target.value)} /></label>
|
||||
<Button className="self-start" variant="outline" disabled={busy || !operationId} onClick={() => { void run(() => api.restoreReceipt(operationId)) }}>Прочитать внешнюю квитанцию</Button>
|
||||
{result !== null && <pre role="status" className="max-h-60 overflow-auto whitespace-pre-wrap break-all text-xs">{JSON.stringify(result, null, 2)}</pre>}
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useTable, type ColumnDef } from "@tanstack/react-table"
|
||||
import { BotIcon, CopyIcon, PlusIcon, RefreshCwIcon, ShieldIcon, StopCircleIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { mcpConnectionCreateSchema, type McpConnection, type McpOperationDescriptor } from "@mmapp/contracts/mcp"
|
||||
import { routeAiProfileSchema, type RouteAiProfile } from "@mmapp/contracts/mcp-routing"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { can, getClaims, hasPermission } from "@/lib/auth"
|
||||
import { DATA_GRID_LABELS_RU } from "@/lib/data-grid-labels-ru"
|
||||
import { mcpApi, type McpApi } from "@/shared/api/mcp"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DatabaseRestore } from "./database-restore"
|
||||
import { PlanReview } from "./plan-review"
|
||||
import { ArtifactDownloads } from "./artifact-downloads"
|
||||
import { PollStorageCapacity } from "./poll-storage-capacity"
|
||||
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame"
|
||||
import { DataGrid, DataGridContainer, DataGridTable, DataGridScrollArea, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||
|
||||
const errorText = (error: unknown) => error instanceof Error ? error.message : "Не удалось выполнить запрос"
|
||||
const modeLabels = { observer: "Наблюдатель", approval: "С подтверждением", autonomous: "Автономный" }
|
||||
const tabs = { overview: "Обзор", connections: "Подключения", permissions: "Разрешения", devices: "Интерфейсы", requests: "Обращения", plans: "Планы и операции", routing: "Route AI", settings: "Настройки" }
|
||||
type Tab = keyof typeof tabs
|
||||
type RecordRow = Record<string, unknown>
|
||||
function pretty(value: unknown) { return JSON.stringify(value, null, 2) }
|
||||
function date(value: unknown) { return value ? new Date(String(value)).toLocaleString("ru-RU") : "—" }
|
||||
function useQuery<T>(load: (signal: AbortSignal) => Promise<T>) {
|
||||
const [data, setData] = useState<T | null>(null), [error, setError] = useState("")
|
||||
const [updatedAt, setUpdatedAt] = useState<string | null>(null)
|
||||
const refresh = useCallback(() => { window.dispatchEvent(new Event("mcp-refresh")) }, [])
|
||||
useEffect(() => {
|
||||
let alive = true, revision = 0, controller: AbortController | undefined
|
||||
const run = async () => {
|
||||
if (document.hidden) return
|
||||
controller?.abort(); controller = new AbortController(); const requestRevision = ++revision
|
||||
try { const result = await load(controller.signal); if (alive && requestRevision === revision) { setData(result); setError(""); setUpdatedAt(new Date().toISOString()) } }
|
||||
catch (failure) { if (alive && requestRevision === revision && !controller.signal.aborted) setError(errorText(failure)) }
|
||||
}
|
||||
queueMicrotask(() => { void run() })
|
||||
const tick = () => { void run() }, timer = window.setInterval(tick, 5000)
|
||||
window.addEventListener("mcp-refresh", tick); document.addEventListener("visibilitychange", tick)
|
||||
return () => { alive = false; revision++; controller?.abort(); window.clearInterval(timer); window.removeEventListener("mcp-refresh", tick); document.removeEventListener("visibilitychange", tick) }
|
||||
}, [load])
|
||||
return { data, error, refresh, updatedAt }
|
||||
}
|
||||
function Panel({ title, description, children, actions }: { title: string; description?: string; children: ReactNode; actions?: ReactNode }) {
|
||||
return <Frame dense><FrameHeader className="flex-row flex-wrap items-center justify-between gap-3"><div><FrameTitle>{title}</FrameTitle>{description && <FrameDescription>{description}</FrameDescription>}</div>{actions}</FrameHeader><FramePanel className="flex min-w-0 flex-col gap-4 p-4">{children}</FramePanel></Frame>
|
||||
}
|
||||
function ErrorMessage({ message }: { message: string }) { return message ? <p role="alert" className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{message}</p> : null }
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) { return <label className="flex min-w-0 flex-col gap-1.5 text-sm"><span>{label}</span>{children}</label> }
|
||||
function Choice({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) { return <label className="flex items-center gap-2 text-sm"><Checkbox checked={checked} onCheckedChange={checked => onChange(!!checked)} />{label}</label> }
|
||||
function NativeSelect({ label, value, onChange, children }: { label: string; value: string; onChange: (value: string) => void; children: ReactNode }) {
|
||||
return <Field label={label}><select aria-label={label} className="h-9 min-w-0 rounded-md border border-input bg-background px-2 text-sm" value={value} onChange={event => onChange(event.target.value)}>{children}</select></Field>
|
||||
}
|
||||
function Grid({ items, columns, loading, total, onSelect }: { items: RecordRow[]; columns: ColumnDef<DataGridFeatures, RecordRow>[]; loading?: boolean; total?: number; onSelect?: (row: RecordRow) => void }) {
|
||||
const table = useTable({ features: dataGridFeatures, data: items, columns, getRowId: (row, index) => String(row.id ?? index), manualPagination: true, rowCount: total ?? items.length })
|
||||
return <DataGrid table={table} recordCount={total ?? items.length} isLoading={loading} onRowClick={onSelect} i18n={{ labels: DATA_GRID_LABELS_RU }} emptyMessage="Записей пока нет" tableLayout={{ dense: true, width: "fixed" }}><DataGridContainer><DataGridScrollArea className="max-h-[32rem]"><DataGridTable /></DataGridScrollArea></DataGridContainer></DataGrid>
|
||||
}
|
||||
type DeviceResource = Awaited<ReturnType<McpApi["deviceCatalog"]>>["resources"][number]
|
||||
function DeviceManagement({ api }: { api: McpApi }) {
|
||||
const connections = useQuery(useCallback(signal => api.connections(signal), [api]))
|
||||
const [connectionId, setConnectionId] = useState("")
|
||||
return <Panel title="Интерфейсы и адреса" description="Чтение и подготовка изменений от имени выбранного подключения. Подтверждение и применение — на вкладке «Планы и операции»."><ErrorMessage message={connections.error} /><NativeSelect label="Подключение для управления интерфейсами" value={connectionId} onChange={setConnectionId}><option value="">Выберите подключение</option>{connections.data?.connections.filter(connection => connection.ownerId === getClaims()?.sub && connection.policy.status === "active").map(connection => <option key={connection.id} value={connection.id}>{connection.name}</option>)}</NativeSelect>{connectionId && <DeviceCatalog key={connectionId} api={api} connectionId={connectionId} />}</Panel>
|
||||
}
|
||||
function DeviceCatalog({ api, connectionId }: { api: McpApi; connectionId: string }) {
|
||||
const catalog = useQuery(useCallback(signal => api.deviceCatalog(connectionId, signal), [api, connectionId]))
|
||||
const [serverId, setServerId] = useState(""), [resourceId, setResourceId] = useState("")
|
||||
const resource = catalog.data?.resources.find(resource => resource.id === resourceId)
|
||||
return <><ErrorMessage message={catalog.error} /><div className="grid gap-3 sm:grid-cols-2"><NativeSelect label="Сервер интерфейсов" value={serverId} onChange={setServerId}><option value="">Выберите сервер</option>{catalog.data?.servers.map(server => <option key={server.id} value={String(server.id)} disabled={!server.enabled}>{server.name} · {server.host}</option>)}</NativeSelect><NativeSelect label="Тип объектов устройства" value={resourceId} onChange={setResourceId}><option value="">Выберите тип</option>{catalog.data?.resources.filter(resource => resource.read).map(resource => <option key={resource.id} value={resource.id}>{resource.title}</option>)}</NativeSelect></div>{catalog.data && !catalog.data.resources.some(resource => resource.read) && <p className="text-sm text-muted-foreground">Подключению не выданы инструменты чтения интерфейсов.</p>}{resource && serverId && catalog.data?.servers.some(server => String(server.id) === serverId && server.enabled) && <DeviceEditor key={`${serverId}:${resourceId}`} api={api} connectionId={connectionId} serverId={serverId} resource={resource} />}</>
|
||||
}
|
||||
function DeviceEditor({ api, connectionId, serverId, resource }: { api: McpApi; connectionId: string; serverId: string; resource: DeviceResource }) {
|
||||
const [offset, setOffset] = useState(0), [editing, setEditing] = useState<RecordRow | "new" | null>(null), [draft, setDraft] = useState<Record<string, unknown>>({})
|
||||
const [busy, setBusy] = useState(false), [error, setError] = useState(""), [receipt, setReceipt] = useState("")
|
||||
const rows = useQuery(useCallback(signal => api.deviceRows(connectionId, serverId, resource.id, offset, signal), [api, connectionId, serverId, resource.id, offset]))
|
||||
const open = (row: RecordRow | "new") => { setEditing(row); setDraft({}); setError(""); setReceipt("") }
|
||||
const prepare = async (action: "create" | "update" | "delete") => {
|
||||
setBusy(true); setError(""); setReceipt("")
|
||||
try {
|
||||
const result = await api.devicePlan(connectionId, serverId, resource.id, action, editing && editing !== "new" ? String(editing[".id"]) : undefined, action === "delete" ? {} : draft)
|
||||
setReceipt(`Создан план ${result.planId}. Проверьте изменения, подтвердите и примените его на вкладке «Планы и операции».`); setEditing(null); rows.refresh()
|
||||
} catch (failure) { setError(errorText(failure)) } finally { setBusy(false) }
|
||||
}
|
||||
const current = editing && editing !== "new" ? editing : null
|
||||
const protectedRow = current && ["dynamic", "builtin", "default"].some(key => current[key] === "true" || current[key] === true)
|
||||
const change = (key: string, value: unknown) => setDraft(fields => { const next = { ...fields }; if (value === undefined) delete next[key]; else next[key] = value; return next })
|
||||
return <><ErrorMessage message={rows.error || error} />{receipt && <p role="status" className="text-sm">{receipt}</p>}<div className="flex flex-wrap items-center justify-between gap-2"><p className="text-sm text-muted-foreground">Объектов: {rows.data?.total ?? "…"}. Выберите строку для просмотра и подготовки изменения.</p>{resource.create && <Button variant="outline" disabled={busy} onClick={() => open("new")}><PlusIcon />Создать объект</Button>}</div><Grid items={rows.data?.items ?? []} loading={!rows.data && !rows.error} total={rows.data?.total} columns={[{ id: "nativeId", header: "ID", cell: ({ row }) => String(row.original[".id"] ?? "—") }, { id: "name", header: "Объект", cell: ({ row }) => String(row.original.name ?? row.original.address ?? row.original.interface ?? "—") }, { id: "state", header: "Состояние", cell: ({ row }) => String(row.original.disabled === "true" || row.original.disabled === true ? "Выключен" : row.original.running ?? "—") }]} onSelect={row => { if (row[".id"] && !busy) open(row) }} /><div className="flex gap-2"><Button variant="outline" disabled={busy || offset === 0} onClick={() => { setOffset(Math.max(0, offset - 100)); setEditing(null) }}>Предыдущие объекты</Button><Button variant="outline" disabled={busy || rows.data?.nextOffset == null} onClick={() => { if (rows.data?.nextOffset != null) setOffset(rows.data.nextOffset); setEditing(null) }}>Следующие объекты</Button></div>{editing && <div className="flex flex-col gap-4 rounded-md border p-4"><p className="font-medium">{editing === "new" ? "Новый объект" : `Объект ${String(current?.[".id"])}`}</p>{current && <details><summary className="cursor-pointer text-sm">Текущие свойства</summary><pre className="max-h-60 overflow-auto whitespace-pre-wrap break-all text-xs">{pretty(current)}</pre></details>}{protectedRow ? <p className="text-sm">Системный или динамический объект доступен для чтения.</p> : <><p className="text-xs text-muted-foreground">В план попадут только заполненные изменения. Пустой выбор оставляет свойство без изменения; при создании действует настройка устройства.</p><div className="grid gap-3 sm:grid-cols-2">{resource.fields.filter(field => editing === "new" || field.key in (current ?? {})).map(field => <Field key={field.key} label={`${field.key}${editing === "new" && field.requiredOnCreate ? " *" : ""}`}>{field.type === "boolean" || field.options ? <select aria-label={field.key} className="h-9 min-w-0 rounded-md border border-input bg-background px-2 text-sm" value={String(draft[field.key] ?? "")} disabled={busy || editing !== "new" && !resource.update} onChange={event => change(field.key, event.target.value === "" ? undefined : field.type === "boolean" ? event.target.value === "true" : event.target.value)}><option value="">{current ? `Сейчас: ${String(current[field.key])}` : "По умолчанию устройства"}</option>{(field.options ?? ["true", "false"]).map(value => <option key={value} value={value}>{value}</option>)}</select> : <Input aria-label={field.key} type={field.type === "number" ? "number" : "text"} min={field.min} max={field.max} maxLength={field.maxLength} disabled={busy || editing !== "new" && !resource.update} placeholder={current ? String(current[field.key] ?? "") : ""} value={String(draft[field.key] ?? "")} onChange={event => change(field.key, field.type === "number" ? event.target.value === "" ? undefined : Number(event.target.value) : event.target.value)} />}</Field>)}</div></>}<div className="flex flex-wrap gap-2">{!protectedRow && (editing === "new" ? resource.create : resource.update) && <Button disabled={busy || !Object.keys(draft).length} onClick={() => { void prepare(editing === "new" ? "create" : "update") }}>Подготовить план изменения</Button>}{current && !protectedRow && resource.delete && <Button variant="destructive" disabled={busy} onClick={() => { void prepare("delete") }}>Подготовить план удаления</Button>}<Button variant="outline" disabled={busy} onClick={() => setEditing(null)}>Закрыть объект</Button></div></div>}</>
|
||||
}
|
||||
function TokenDialog({ token, close }: { token: string; close: () => void }) {
|
||||
return <Dialog open={!!token} onOpenChange={open => { if (!open) close() }}><DialogContent className="sm:max-w-xl"><DialogHeader><DialogTitle>Доступ создан</DialogTitle><DialogDescription>Сохраните токен в настройках AI-клиента. Повторно посмотреть его нельзя.</DialogDescription></DialogHeader><Input aria-label="Одноразовый токен MCP" readOnly type="password" value={token} /><Button onClick={() => { void navigator.clipboard.writeText(token).then(() => toast.success("Токен скопирован"), () => toast.error("Не удалось скопировать токен")) }}><CopyIcon />Копировать</Button><Button variant="outline" onClick={close}>Закрыть и скрыть токен</Button></DialogContent></Dialog>
|
||||
}
|
||||
function ConnectionForm({ api, operations, editing, close, created }: { api: McpApi; operations: McpOperationDescriptor[]; editing?: McpConnection; close: () => void; created: (token?: string) => void }) {
|
||||
const [step, setStep] = useState(1), [busy, setBusy] = useState(false), [error, setError] = useState("")
|
||||
const [name, setName] = useState(editing?.name ?? ""), [mode, setMode] = useState<McpConnection["policy"]["mode"]>(editing?.policy.mode ?? "observer")
|
||||
const [serverText, setServerText] = useState(editing?.policy.scope.serverIds.join(", ") ?? ""), [selected, setSelected] = useState<string[]>(editing?.policy.scope.operationIds ?? [])
|
||||
const [global, setGlobal] = useState(editing?.policy.scope.allowGlobal ?? false), [danger, setDanger] = useState(editing?.policy.dangerousPolicy ?? "approval")
|
||||
const [dangerIds, setDangerIds] = useState(editing?.policy.autonomousDangerousOperationIds ?? [])
|
||||
const [expires, setExpires] = useState(() => editing?.policy.expiresAt.slice(0, 16) ?? new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 16))
|
||||
const [limits, setLimits] = useState(editing?.policy.limits ?? { callsPerMinute: 60, concurrentReads: 4, mutationsPerHour: 10, serversPerPlan: 5, objectsPerPlan: 100 })
|
||||
const permissions = getClaims()?.permissions ?? []
|
||||
const eligible = operations.filter(operation => operation.implementation === "available" && operation.requiredPermissions.every(permission => hasPermission(permissions, permission)))
|
||||
const toggle = (id: string) => setSelected(ids => ids.includes(id) ? ids.filter(value => value !== id) : [...ids, id])
|
||||
const save = async () => {
|
||||
setBusy(true); setError("")
|
||||
try {
|
||||
const input = mcpConnectionCreateSchema.parse({ name, mode, expiresAt: new Date(expires + "Z").toISOString(), permissions: [...new Set(eligible.filter(operation => selected.includes(operation.id)).flatMap(operation => operation.requiredPermissions))], scope: { serverIds: serverText.split(/[,\s]+/).filter(Boolean).map(Number), operationIds: selected, allowGlobal: global }, dangerousPolicy: danger, autonomousDangerousOperationIds: dangerIds.filter(id => selected.includes(id)), limits })
|
||||
if (editing) { await api.update(editing.id, input); created() }
|
||||
else { const result = await api.create(input); created(result.token) }
|
||||
} catch (failure) { setError(errorText(failure)) } finally { setBusy(false) }
|
||||
}
|
||||
return <Dialog open onOpenChange={open => { if (!open && !busy) close() }}><DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-3xl"><DialogHeader><DialogTitle>{editing ? "Изменить разрешения" : "Новое подключение"}</DialogTitle><DialogDescription>Шаг {step} из 4 · доступ ограничивается правами текущего владельца.</DialogDescription></DialogHeader><ErrorMessage message={error} />
|
||||
{step === 1 && <><Field label="Название"><Input value={name} onChange={event => setName(event.target.value)} placeholder="Агент диагностики" /></Field><Field label="Срок действия (UTC)"><Input type="datetime-local" value={expires} onChange={event => setExpires(event.target.value)} /></Field><NativeSelect label="Режим управления" value={mode} onChange={value => setMode(value as typeof mode)}>{Object.entries(modeLabels).map(([value, title]) => <option key={value} value={value}>{title}</option>)}</NativeSelect></>}
|
||||
{step === 2 && <><Field label="ID разрешённых серверов через запятую"><Input value={serverText} onChange={event => setServerText(event.target.value)} placeholder="1, 2" /></Field><Choice label="Разрешить выбранные глобальные операции" checked={global} onChange={setGlobal} /><div className="grid max-h-80 gap-2 overflow-y-auto sm:grid-cols-2">{eligible.map(operation => <Choice key={operation.id} label={operation.title} checked={selected.includes(operation.id)} onChange={() => toggle(operation.id)} />)}</div><p className="text-xs text-muted-foreground">Новые инструменты не добавляются в доступ автоматически. Для применения планов выберите также инструменты просмотра и применения.</p></>}
|
||||
{step === 3 && <><div className="grid gap-3 sm:grid-cols-2">{Object.entries(limits).map(([key, value]) => <Field key={key} label={({ callsPerMinute: "Вызовов в минуту", concurrentReads: "Одновременных чтений", mutationsPerHour: "Изменений в час", serversPerPlan: "Серверов на план", objectsPerPlan: "Объектов на план" } as Record<string, string>)[key]}><Input type="number" min={1} value={value} onChange={event => setLimits(current => ({ ...current, [key]: Number(event.target.value) }))} /></Field>)}</div><NativeSelect label="Опасные действия" value={danger} onChange={value => setDanger(value as typeof danger)}><option value="approval">Всегда подтверждать</option><option value="explicit-allow">Автономно для явно выбранных действий</option></NativeSelect>{danger === "explicit-allow" && eligible.filter(operation => operation.risk === "dangerous" && selected.includes(operation.id)).map(operation => <Choice key={operation.id} label={operation.title} checked={dangerIds.includes(operation.id)} onChange={checked => setDangerIds(ids => checked ? [...ids, operation.id] : ids.filter(id => id !== operation.id))} />)}</>}
|
||||
{step === 4 && <div className="space-y-3 text-sm"><p><strong>{name || "Без названия"}</strong> · {modeLabels[mode]}</p><p>Серверы: {serverText || "не выбраны"}. Операций: {selected.length}. Глобальный доступ: {global ? "да" : "нет"}.</p><p>Защита объектов продолжает действовать независимо от автономных разрешений.</p><p>После сохранения проверьте конкретное действие в симуляторе на вкладке «Разрешения».</p></div>}
|
||||
<div className="flex flex-wrap justify-between gap-2"><Button variant="outline" disabled={busy} onClick={() => step > 1 ? setStep(step - 1) : close()}>{step > 1 ? "Назад" : "Отмена"}</Button>{step < 4 ? <Button onClick={() => setStep(step + 1)}>Далее</Button> : <Button disabled={busy} onClick={() => { void save() }}>{busy ? "Сохранение…" : "Сохранить доступ"}</Button>}</div>
|
||||
</DialogContent></Dialog>
|
||||
}
|
||||
function Connections({ api, operations }: { api: McpApi; operations: McpOperationDescriptor[] }) {
|
||||
const query = useQuery(useCallback(signal => api.connections(signal), [api]))
|
||||
const [editing, setEditing] = useState<McpConnection | "new" | null>(null), [token, setToken] = useState("")
|
||||
const admin = can("mm:mcp:admin")
|
||||
const refresh = query.refresh
|
||||
const act = useCallback(async (work: () => Promise<unknown>) => { try { await work(); refresh() } catch (error) { toast.error(errorText(error)) } }, [refresh])
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, RecordRow>[]>(() => [
|
||||
{ accessorKey: "name", header: "Подключение", size: 200 },
|
||||
{ id: "mode", header: "Режим", cell: ({ row }) => modeLabels[(row.original.policy as McpConnection["policy"]).mode] },
|
||||
{ id: "status", header: "Состояние", cell: ({ row }) => <Badge variant={(row.original.policy as McpConnection["policy"]).status === "active" && row.original.generationValid ? "success-light" : "warning-light"}>{row.original.generationValid ? (row.original.policy as McpConnection["policy"]).status : "доступ аннулирован"}</Badge> },
|
||||
{ accessorKey: "lastUsedAt", header: "Последний вызов", cell: ({ row }) => date(row.original.lastUsedAt) },
|
||||
{ id: "ownerValidation", header: "Права владельца", size: 190, cell: ({ row }) => { const connection = row.original as unknown as McpConnection; return <div className="flex flex-col gap-1"><span className="text-xs">До {date(connection.ownerValidUntil)}</span>{connection.ownerId === getClaims()?.sub && <Button size="sm" variant="outline" disabled={!connection.generationValid || connection.policy.status === "revoked"} onClick={() => { void act(() => api.revalidate(connection.id)) }}>Обновить мои права</Button>}</div> } },
|
||||
{ id: "actions", header: "Действия", size: 350, cell: ({ row }) => { const connection = row.original as unknown as McpConnection; return admin && <div className="flex flex-wrap gap-1"><Button size="sm" variant="outline" onClick={() => setEditing(connection)}>Разрешения</Button><Button size="sm" variant="outline" disabled={connection.policy.status === "revoked"} onClick={() => { void act(() => api.status(connection.id, connection.policy.status === "active" ? "paused" : "active")) }}>{connection.policy.status === "active" ? "Пауза" : "Включить"}</Button><Button size="sm" variant="outline" disabled={connection.policy.status === "revoked"} onClick={() => { if (window.confirm("Отозвать доступ подключения и все его токены?")) void act(() => api.status(connection.id, "revoked")) }}>Отозвать</Button><Button size="sm" variant="ghost" disabled={connection.policy.status === "revoked"} onClick={() => { if (window.confirm("Заменить токен? Старый токен перестанет работать.")) void act(async () => setToken((await api.rotate(connection.id)).token)) }}>Новый токен</Button></div> } },
|
||||
], [api, admin, act])
|
||||
return <><Panel title="Подключения" description="Токены, сроки и разрешения AI-клиентов" actions={admin && <Button onClick={() => setEditing("new")}><PlusIcon />Выдать доступ</Button>}><ErrorMessage message={query.error} /><Grid items={(query.data?.connections ?? []) as unknown as RecordRow[]} columns={columns} loading={!query.data && !query.error} /><p className="text-sm text-muted-foreground">HTTP endpoint: адрес backend + /mcp. Для stdio используйте MCP_URL и MCP_TOKEN с командой npm --prefix backend run mcp:stdio.</p></Panel>{editing && <ConnectionForm api={api} operations={operations} editing={editing === "new" ? undefined : editing} close={() => setEditing(null)} created={value => { setEditing(null); if (value) setToken(value); query.refresh() }} />}<TokenDialog token={token} close={() => setToken("")} /></>
|
||||
}
|
||||
function AccessTools({ api }: { api: McpApi }) {
|
||||
const connections = useQuery(useCallback(signal => api.connections(signal), [api]))
|
||||
const [selected, setSelected] = useState(""), [result, setResult] = useState<unknown>(null), [token, setToken] = useState("")
|
||||
const [operations, setOperations] = useState<string[]>([]), [serverText, setServerText] = useState(""), [minutes, setMinutes] = useState(60), [global, setGlobal] = useState(false)
|
||||
const grantLoad = useCallback(async (signal: AbortSignal) => selected ? api.grants(selected, signal) : { grants: [] }, [api, selected])
|
||||
const grants = useQuery(grantLoad), connection = connections.data?.connections.find(value => value.id === selected)
|
||||
const create = async () => {
|
||||
if (!connection) return
|
||||
try {
|
||||
const now = new Date(), expires = new Date(Math.min(now.getTime() + minutes * 60000, Date.parse(connection.policy.expiresAt)))
|
||||
const issued = await api.grant(connection.id, { id: crypto.randomUUID(), startsAt: now.toISOString(), expiresAt: expires.toISOString(), scope: { serverIds: serverText.split(",").map(value => value.trim()).filter(Boolean).map(Number), operationIds: operations, allowGlobal: global } })
|
||||
setToken(issued.token); grants.refresh()
|
||||
} catch (error) { toast.error(errorText(error)) }
|
||||
}
|
||||
return <Panel title="Проверка подключения и временные допуски" description="Допуск с отдельным токеном может только сузить основной доступ и действует не более 24 часов"><ErrorMessage message={connections.error || grants.error} /><NativeSelect label="Подключение для проверки" value={selected} onChange={value => { setSelected(value); setOperations([]); setServerText(""); setGlobal(false); setResult(null) }}><option value="">Выберите подключение</option>{connections.data?.connections.map(connection => <option key={connection.id} value={connection.id}>{connection.name}</option>)}</NativeSelect>{connection && <><p className="text-sm text-muted-foreground">Права владельца подтверждены до {date(connection.ownerValidUntil)}. Для продления войдите с актуальными правами портала; старые планы и допуски контроллера потребуется подготовить заново.</p><Button variant="outline" className="self-start" disabled={connection.ownerId !== getClaims()?.sub} onClick={() => { void api.revalidate(connection.id).then(() => { connections.refresh(); toast.success("Права владельца обновлены") }, error => toast.error(errorText(error))) }}>Подтвердить текущие права владельца</Button><Button variant="outline" className="self-start" onClick={() => { void api.check(connection.id).then(setResult, error => toast.error(errorText(error))) }}>Проверить готовность доступа</Button>{result !== null && <pre className="overflow-auto rounded bg-muted p-3 text-xs">{pretty(result)}</pre>}<div className="grid gap-3 sm:grid-cols-2"><Field label={`Серверы допуска (доступны: ${connection.policy.scope.serverIds.join(", ")})`}><Input value={serverText} onChange={event => setServerText(event.target.value)} placeholder="ID через запятую" /></Field><Field label="Срок допуска, минут"><Input type="number" min={1} max={1440} value={minutes} onChange={event => setMinutes(Number(event.target.value))} /></Field></div>{connection.policy.scope.allowGlobal && <Choice label="Разрешить глобальные действия в допуске" checked={global} onChange={setGlobal} />}<div className="grid max-h-60 gap-2 overflow-y-auto sm:grid-cols-2">{connection.policy.scope.operationIds.map(id => <Choice key={id} label={id} checked={operations.includes(id)} onChange={checked => setOperations(values => checked ? [...values, id] : values.filter(value => value !== id))} />)}</div><Button className="self-start" disabled={!operations.length || connection.policy.status !== "active"} onClick={() => { void create() }}>Выдать временный допуск</Button>{grants.data?.grants.map(grant => <div key={grant.id} className="flex flex-wrap items-center justify-between gap-2 rounded border p-3 text-sm"><span>До {date(grant.grant.expiresAt)} · {grant.revoked_at ? "отозван" : "выдан"}</span><Button size="sm" variant="outline" disabled={!!grant.revoked_at} onClick={() => { void api.revokeGrant(grant.id).then(grants.refresh, error => toast.error(errorText(error))) }}>Отозвать допуск</Button></div>)}</>}<TokenDialog token={token} close={() => setToken("")} /></Panel>
|
||||
}
|
||||
function RoutingProfileForm({ api, editing, close }: { api: McpApi; editing?: RecordRow; close: () => void }) {
|
||||
const connections = useQuery(useCallback(signal => api.connections(signal), [api]))
|
||||
const original = editing ? routeAiProfileSchema.parse(editing.config) : null
|
||||
const [connectionId, setConnectionId] = useState(String(editing?.connection_id ?? "")), [name, setName] = useState(original?.name ?? ""), [serverId, setServerId] = useState(String(original?.serverId ?? ""))
|
||||
const [community, setCommunity] = useState(original?.community ?? ""), [family, setFamily] = useState(original?.family ?? "ipv4"), [table, setTable] = useState(original?.table ?? "main")
|
||||
const [probeVrf, setProbeVrf] = useState(original?.probeVrf ?? "main")
|
||||
const [bindBgp, setBindBgp] = useState(!!original?.bgpContext)
|
||||
const [bgp, setBgp] = useState(original?.bgpContext ?? { connectionId: "", instance: "", inputChain: "", vrf: "main" })
|
||||
const executionReason = table.trim() !== "main" || probeVrf.trim() !== "main"
|
||||
? "Автоматическое переключение для отдельной таблицы маршрутов или VRF проб пока недоступно."
|
||||
: bindBgp && (bgp.inputChain.trim() !== "bgp-in" || bgp.vrf.trim() !== "main")
|
||||
? "Автоматическое переключение для этой входящей цепочки BGP или транспортной VRF пока недоступно."
|
||||
: ""
|
||||
const [enabled, setEnabled] = useState(editing?.enabled === true), [expires, setExpires] = useState(() => new Date(Date.now() + 1800000).toISOString().slice(0, 16))
|
||||
const [autoRecovery, setAutoRecovery] = useState(original?.autoRecovery ?? false)
|
||||
const controllerReason = autoRecovery ? "Автовосстановление Community пока недоступно. Отключите его для активации контроллера." : executionReason
|
||||
const [weights, setWeights] = useState(original?.weights ?? { ping: 60, speed: 30, loss: 10 }), [threshold, setThreshold] = useState(original?.switchThreshold ?? 15), [cooldown, setCooldown] = useState(original?.cooldownSec ?? 600)
|
||||
const [pin, setPin] = useState(original?.pinnedCandidateId ?? ""), [excluded, setExcluded] = useState<string[]>(original?.excludedCandidateIds ?? [])
|
||||
const [sla, setSla] = useState({ ping: original?.maxPingMs?.toString() ?? "", loss: original?.maxLossPct?.toString() ?? "", speed: original?.minBandwidthMbps?.toString() ?? "" })
|
||||
const [candidates, setCandidates] = useState<CandidateForm[]>(() => original?.candidates.map(candidate => ({ ...candidate, jumpHostId: String(candidate.jumpHostId), exitNodeId: String(candidate.exitNodeId) })) ?? [{ id: "path-1", wanInterface: "", jumpHostId: "", exitNodeId: "", downstreamInterface: "", gateway: "", gatewayRef: { resource: "gre" as "gre" | "ipv4_route" | "ipv6_route", id: "" }, wanRemoteInterface: "", downstreamRemoteInterface: "" }])
|
||||
const [error, setError] = useState(""), [busy, setBusy] = useState(false), [plan, setPlan] = useState("")
|
||||
const save = async () => {
|
||||
setBusy(true); setError("")
|
||||
try {
|
||||
const profile = routeAiProfileSchema.parse({ ...original, name, serverId: Number(serverId), community, family, table, probeVrf, bgpContext: bindBgp ? bgp : undefined, weights, switchThreshold: threshold, cooldownSec: cooldown, autoRecovery, pinnedCandidateId: pin || null, excludedCandidateIds: excluded,
|
||||
maxPingMs: sla.ping ? Number(sla.ping) : null, maxLossPct: sla.loss ? Number(sla.loss) : null, minBandwidthMbps: sla.speed ? Number(sla.speed) : null,
|
||||
candidates: candidates.map(candidate => ({ ...candidate, jumpHostId: Number(candidate.jumpHostId), exitNodeId: Number(candidate.exitNodeId), wanRemoteInterface: candidate.wanRemoteInterface || undefined, downstreamRemoteInterface: candidate.downstreamRemoteInterface || undefined })) })
|
||||
if (enabled && controllerReason) throw new Error(controllerReason + " Отключите контроллер для сохранения профиля.")
|
||||
const result = await api.profilePlan({ connectionId, ...(editing ? { profileId: editing.id } : {}), profile, enabled, expiresAt: new Date(expires + "Z").toISOString() })
|
||||
setPlan(result.planId)
|
||||
} catch (failure) { setError(errorText(failure)) } finally { setBusy(false) }
|
||||
}
|
||||
return <Dialog open onOpenChange={open => { if (!open && !busy) close() }}><DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-4xl"><DialogHeader><DialogTitle>{editing ? "Изменить профиль Route AI" : "Новый профиль Route AI"}</DialogTitle><DialogDescription>Проверьте пути и допуск. Сохранение создаёт план для отдельного подтверждения и применения.</DialogDescription></DialogHeader><ErrorMessage message={error || connections.error} />{plan ? <><p>Создан план {plan}. Откройте «Планы и операции», проверьте изменения, подтвердите и примените план.</p><Button onClick={close}>Закрыть</Button></> : <><div className="grid gap-3 sm:grid-cols-2"><NativeSelect label="Подключение контроллера" value={connectionId} onChange={setConnectionId}><option value="">Выберите подключение владельца</option>{connections.data?.connections.filter(connection => connection.ownerId === getClaims()?.sub && connection.policy.status === "active" && connection.policy.mode !== "observer" && connection.policy.scope.operationIds.includes("routing.profiles.save")).map(connection => <option key={connection.id} value={connection.id}>{connection.name}</option>)}</NativeSelect><Field label="Название профиля"><Input value={name} onChange={event => setName(event.target.value)} /></Field><Field label="ID домашнего сервера"><Input type="number" min={1} value={serverId} onChange={event => setServerId(event.target.value)} /></Field><Field label="Community"><Input value={community} onChange={event => setCommunity(event.target.value)} placeholder="65000:10" /></Field><NativeSelect label="Семейство адресов" value={family} onChange={value => setFamily(value as typeof family)}><option value="ipv4">IPv4</option><option value="ipv6">IPv6</option></NativeSelect><Field label="Таблица маршрутизации"><Input value={table} onChange={event => setTable(event.target.value)} /></Field><Field label="VRF диагностических проб"><Input value={probeVrf} onChange={event => setProbeVrf(event.target.value)} /></Field></div>
|
||||
<p className="text-sm text-muted-foreground">Таблица определяет установку маршрутов. VRF диагностических проб определяет контекст измерений и задаётся отдельно от транспортной VRF BGP.</p>
|
||||
<Choice label="Привязать профиль к конкретному BGP-подключению RouterOS" checked={bindBgp} onChange={setBindBgp} />
|
||||
{bindBgp ? <fieldset className="rounded-lg border p-3"><legend className="px-1 text-sm font-medium">Контекст BGP</legend><div className="grid gap-3 sm:grid-cols-2">{([{ key: "connectionId", title: "ID BGP-подключения RouterOS" }, { key: "instance", title: "Экземпляр BGP" }, { key: "inputChain", title: "Входящая цепочка BGP" }, { key: "vrf", title: "Транспортная VRF BGP" }] as const).map(field => <Field key={field.key} label={field.title}><Input value={bgp[field.key]} placeholder={field.key === "connectionId" ? "*A" : undefined} onChange={event => setBgp(values => ({ ...values, [field.key]: event.target.value }))} /></Field>)}</div><p className="mt-2 text-sm text-muted-foreground">Заполните все поля. Снятие привязки явно удалит контекст BGP из плана профиля.</p></fieldset> : <p className="text-sm text-muted-foreground">Подключение BGP будет определено по наблюдаемой конфигурации; неоднозначная привязка блокирует переключение.</p>}
|
||||
{controllerReason && <p role="status" className="text-sm text-muted-foreground">{controllerReason} Профиль можно сохранить с выключенным контроллером для анализа и подготовки предложений.</p>}
|
||||
{candidates.map((candidate, index) => <fieldset key={candidate.id} className="rounded-lg border p-3"><legend className="px-1 text-sm font-medium">Путь {index + 1} · {candidate.id}</legend><div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">{([{ key: "wanInterface", title: "WAN-интерфейс дома" }, { key: "jumpHostId", title: "ID JumpHost" }, { key: "exitNodeId", title: "ID ExitNode" }, { key: "downstreamInterface", title: "Интерфейс JumpHost к ExitNode" }, { key: "gateway", title: "Адрес gateway" }, { key: "wanRemoteInterface", title: "Входящий интерфейс JumpHost" }, { key: "downstreamRemoteInterface", title: "Входящий интерфейс ExitNode" }] as const).map(field => <Field key={field.key} label={field.title}><Input value={candidate[field.key] ?? ""} onChange={event => setCandidates(values => values.map(value => value.id === candidate.id ? { ...value, [field.key]: event.target.value } : value))} /></Field>)}<NativeSelect label="Тип шлюза" value={candidate.gatewayRef.resource} onChange={resource => setCandidates(values => values.map(value => value.id === candidate.id ? { ...value, gatewayRef: { ...value.gatewayRef, resource: resource as typeof value.gatewayRef.resource } } : value))}><option value="gre">GRE</option><option value="ipv4_route">Рекурсивный IPv4</option><option value="ipv6_route">Рекурсивный IPv6</option></NativeSelect><Field label="ID шлюза на устройстве"><Input value={candidate.gatewayRef.id} onChange={event => setCandidates(values => values.map(value => value.id === candidate.id ? { ...value, gatewayRef: { ...value.gatewayRef, id: event.target.value } } : value))} /></Field></div><div className="mt-3 flex flex-wrap items-center gap-3"><Choice label="Исключить путь" checked={excluded.includes(candidate.id)} onChange={checked => setExcluded(values => checked ? [...values, candidate.id] : values.filter(value => value !== candidate.id))} /><Button size="sm" variant="ghost" disabled={candidates.length === 1} onClick={() => { setCandidates(values => values.filter(value => value.id !== candidate.id)); setExcluded(values => values.filter(value => value !== candidate.id)); if (pin === candidate.id) setPin("") }}>Удалить путь</Button></div></fieldset>)}
|
||||
<CandidateProbeEditor candidates={candidates} update={(id, controlProbe) => setCandidates(values => values.map(candidate => candidate.id === id ? { ...candidate, controlProbe } : candidate))} /><Button variant="outline" className="self-start" disabled={candidates.length >= 100} onClick={() => setCandidates(values => [...values, { ...values[0], id: crypto.randomUUID(), gateway: "", gatewayRef: { ...values[0].gatewayRef, id: "" } }])}>Добавить путь</Button><div className="grid gap-3 sm:grid-cols-3">{Object.entries(weights).map(([key, value]) => <Field key={key} label={`Вес ${key}, %`}><Input type="number" min={0} max={100} value={value} onChange={event => setWeights(values => ({ ...values, [key]: Number(event.target.value) }))} /></Field>)}{([{ key: "ping", title: "Максимум RTT, мс" }, { key: "loss", title: "Максимум потерь, %" }, { key: "speed", title: "Минимум скорости, Mbps" }] as const).map(field => <Field key={field.key} label={field.title}><Input type="number" min={0} value={sla[field.key]} placeholder="Без ограничения" onChange={event => setSla(values => ({ ...values, [field.key]: event.target.value }))} /></Field>)}<Field label="Порог улучшения, баллы"><Input type="number" min={0} max={100} value={threshold} onChange={event => setThreshold(Number(event.target.value))} /></Field><Field label="Пауза между переключениями, сек"><Input type="number" min={60} value={cooldown} onChange={event => setCooldown(Number(event.target.value))} /></Field><NativeSelect label="Закреплённый путь" value={pin} onChange={setPin}><option value="">Автоматический выбор</option>{candidates.map((candidate, index) => <option key={candidate.id} value={candidate.id}>Путь {index + 1}</option>)}</NativeSelect></div><Choice label="Разрешить заранее зарезервированное восстановление" checked={autoRecovery} onChange={setAutoRecovery} /><Choice label="Разрешить работу контроллера после применения плана" checked={enabled} onChange={setEnabled} /><Field label="Допуск действует до (UTC, максимум 24 часа)"><Input type="datetime-local" value={expires} onChange={event => setExpires(event.target.value)} /></Field><Button disabled={busy || !connectionId || (!!controllerReason && enabled)} onClick={() => { void save() }}>{busy ? "Подготовка…" : "Подготовить план профиля"}</Button></>}</DialogContent></Dialog>
|
||||
}
|
||||
type CandidateForm = Omit<RouteAiProfile["candidates"][number], "jumpHostId" | "exitNodeId"> & { jumpHostId: string; exitNodeId: string }
|
||||
function CandidateProbeEditor({ candidates, update }: { candidates: CandidateForm[]; update: (id: string, probe: CandidateForm["controlProbe"]) => void }) {
|
||||
return <div className="grid gap-3">{candidates.map((candidate, index) => <fieldset key={candidate.id} className="flex flex-col gap-3 rounded-md border p-3"><legend className="px-1 text-sm">Контрольная проверка пути {index + 1}</legend><Choice label={`Включить контроль пути ${index + 1}`} checked={!!candidate.controlProbe} onChange={checked => update(candidate.id, checked ? { targetInterface: candidate.downstreamRemoteInterface ?? "", expectedHops: [{ serverId: Number(candidate.jumpHostId), interface: candidate.wanRemoteInterface ?? "" }, { serverId: Number(candidate.exitNodeId), interface: candidate.downstreamRemoteInterface ?? "" }] } : undefined)} />{candidate.controlProbe && <><Field label={`Контрольный интерфейс ExitNode пути ${index + 1}`}><Input value={candidate.controlProbe.targetInterface} onChange={event => update(candidate.id, { ...candidate.controlProbe!, targetInterface: event.target.value })} /></Field><Field label={`Исходный IP контрольной пробы пути ${index + 1}`}><Input value={candidate.controlProbe.sourceAddress ?? ""} placeholder="Автоматический выбор RouterOS" onChange={event => update(candidate.id, { ...candidate.controlProbe!, sourceAddress: event.target.value || undefined })} /></Field><Field label={`Конкретный IP ExitNode пути ${index + 1}`}><Input value={candidate.controlProbe.targetAddress ?? ""} placeholder="Для интерфейса с несколькими адресами" onChange={event => update(candidate.id, { ...candidate.controlProbe!, targetAddress: event.target.value || undefined })} /></Field>{candidate.controlProbe.expectedHops.map((hop, hopIndex) => <div key={hopIndex} className="grid gap-3 sm:grid-cols-2"><Field label={`Сервер hop ${hopIndex + 1} пути ${index + 1}`}><Input type="number" min={1} value={hop.serverId || ""} onChange={event => update(candidate.id, { ...candidate.controlProbe!, expectedHops: candidate.controlProbe!.expectedHops.map((item, current) => current === hopIndex ? { ...item, serverId: Number(event.target.value) } : item) })} /></Field><Field label={`IP hop ${hopIndex + 1} пути ${index + 1}`}><Input value={hop.address ?? ""} placeholder="Для интерфейса с несколькими адресами" onChange={event => update(candidate.id, { ...candidate.controlProbe!, expectedHops: candidate.controlProbe!.expectedHops.map((item, current) => current === hopIndex ? { ...item, address: event.target.value || undefined } : item) })} /></Field><Field label={`Интерфейс hop ${hopIndex + 1} пути ${index + 1}`}><Input value={hop.interface} onChange={event => update(candidate.id, { ...candidate.controlProbe!, expectedHops: candidate.controlProbe!.expectedHops.map((item, current) => current === hopIndex ? { ...item, interface: event.target.value } : item) })} /></Field></div>)}<p className="text-xs text-muted-foreground">Проверяются зарегистрированные адреса, порядок hops и достижение ExitNode. Это наблюдение контрольных пакетов; оно не доказывает путь всего пользовательского трафика.</p></>}</fieldset>)}</div>
|
||||
}
|
||||
function RoutePathVerification({ api }: { api: McpApi }) {
|
||||
const profiles = useQuery(useCallback(signal => api.profiles(signal), [api]))
|
||||
const [profileId, setProfileId] = useState(""), [candidateId, setCandidateId] = useState(""), [busy, setBusy] = useState(false), [error, setError] = useState(""), [result, setResult] = useState<unknown>(null)
|
||||
const [proofPurpose, setProofPurpose] = useState<"community" | "control-path">("community")
|
||||
const profile = profiles.data?.profiles.find(profile => profile.id === profileId), config = routeAiProfileSchema.safeParse(profile?.config)
|
||||
const check = async () => { setBusy(true); setError(""); setResult(null); try { setResult(await api.checkRoutePath(profileId, candidateId, proofPurpose)) } catch (failure) { setError(errorText(failure)) } finally { setBusy(false) } }
|
||||
return <Panel title="Контрольная проверка пути" description="Ограниченные ping и traceroute; неизвестный результат не считается отказом пути.">
|
||||
<ErrorMessage message={profiles.error || error} />
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<NativeSelect label="Профиль для проверки пути" value={profileId} onChange={value => { setProfileId(value); setCandidateId(""); setResult(null) }}><option value="">Выберите профиль</option>{profiles.data?.profiles.map(profile => <option key={String(profile.id)} value={String(profile.id)}>{String((profile.config as RecordRow)?.name ?? profile.id)}</option>)}</NativeSelect>
|
||||
<NativeSelect label="Проверяемый путь" value={candidateId} onChange={value => { setCandidateId(value); setResult(null) }}><option value="">Выберите путь</option>{config.success && config.data.candidates.filter(candidate => candidate.controlProbe).map(candidate => <option key={candidate.id} value={candidate.id}>{candidate.id}</option>)}</NativeSelect>
|
||||
<NativeSelect label="Назначение проверки" value={proofPurpose} onChange={value => { setProofPurpose(value as typeof proofPurpose); setResult(null) }}><option value="community">Путь Community</option><option value="control-path">Самостоятельный контрольный путь</option></NativeSelect>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{proofPurpose === "control-path" ? "Проверяется только контрольный путь. Результат не разрешает переключение Community и не подтверждает обратный путь." : "Проверка учитывает Community, таблицу маршрутизации и BGP. Применение изменений требует отдельного плана; обратный путь проверяется отдельно."}</p>
|
||||
<Button disabled={busy || !profileId || !candidateId} onClick={() => { void check() }}>Проверить контрольный путь</Button>
|
||||
{result !== null && <pre role="status" className="max-h-72 overflow-auto whitespace-pre-wrap break-all text-xs">{pretty(result)}</pre>}
|
||||
</Panel>
|
||||
}
|
||||
function RoutingProfiles({ api }: { api: McpApi }) {
|
||||
const query = useQuery(useCallback(signal => api.profiles(signal), [api])), [selected, setSelected] = useState<RecordRow | null>(null)
|
||||
const [editing, setEditing] = useState<RecordRow | "new" | null>(null)
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, RecordRow>[]>(() => [
|
||||
{ id: "name", header: "Профиль", size: 220, cell: ({ row }) => String((row.original.config as RecordRow)?.name ?? row.original.id) },
|
||||
{ id: "community", header: "Community / семейство", cell: ({ row }) => { const config = row.original.config as RecordRow; return `${config?.community} · ${config?.family} · ${config?.table}` } },
|
||||
{ id: "state", header: "Контроллер", cell: ({ row }) => <Badge variant={row.original.enabled ? "success-light" : "warning-light"}>{row.original.enabled ? "включён" : "пауза"}</Badge> },
|
||||
{ id: "reason", header: "Решение", size: 300, cell: ({ row }) => String((row.original.state as RecordRow)?.pausedReason ?? (row.original.state as RecordRow)?.reason ?? "Ожидает проверки") },
|
||||
{ accessorKey: "grant_expires_at", header: "Допуск до", cell: ({ row }) => date(row.original.grant_expires_at) },
|
||||
{ id: "actions", header: "", cell: ({ row }) => can("mm:mcp:admin") && <Button size="sm" variant="outline" disabled={!row.original.enabled} onClick={() => { void api.pauseProfile(String(row.original.id)).then(query.refresh, error => toast.error(errorText(error))) }}>Остановить</Button> },
|
||||
], [api, query.refresh])
|
||||
return <><Panel title="Постоянный контроллер Route AI" description="Профили, временные допуски и решения маршрутизации" actions={can("mm:mcp:admin") && can("mm:network:write") && <Button onClick={() => setEditing("new")}>Создать профиль</Button>}><ErrorMessage message={query.error} /><Grid items={query.data?.profiles ?? []} columns={columns} loading={!query.data && !query.error} onSelect={setSelected} /><p className="text-sm text-muted-foreground">Контроллер учитывает свежесть измерений, закрепления, SLA, гистерезис, cooldown и бюджет переключений. Неизвестный фактический путь не считается подтверждённым.</p><Dialog open={!!selected} onOpenChange={open => { if (!open) setSelected(null) }}><DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-3xl"><DialogHeader><DialogTitle>Профиль Route AI</DialogTitle><DialogDescription>Сохранённые параметры и последнее решение контроллера</DialogDescription></DialogHeader><pre className="overflow-auto whitespace-pre-wrap break-all text-xs">{pretty(selected)}</pre>{can("mm:mcp:admin") && can("mm:network:write") && <Button onClick={() => { setEditing(selected); setSelected(null) }}>Изменить профиль</Button>}</DialogContent></Dialog></Panel>{editing && <RoutingProfileForm api={api} editing={editing === "new" ? undefined : editing} close={() => { setEditing(null); query.refresh() }} />}</>
|
||||
}
|
||||
function Overview({ api }: { api: McpApi }) {
|
||||
const query = useQuery(useCallback(signal => api.overview(signal), [api]))
|
||||
return <Panel title="Управление AI-доступом" description={query.data ? "Последнее обновление: " + date(query.data.observedAt) : "Загрузка состояния"} actions={can("mm:mcp:pause") && <Button variant="outline" onClick={() => { if (window.confirm("Остановить новые обращения MCP и все профили Route AI? Начатые записи будут завершены или сверены.")) void api.stopAutomation().then(query.refresh, error => toast.error(errorText(error))) }}><StopCircleIcon />Остановить автоматические изменения</Button>}><ErrorMessage message={query.error} /><div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">{Object.entries({ connections: "Подключения", calls: "Вызовы за 24 часа", errors: "Ошибки и отказы", approvals: "Ожидают подтверждения", running: "Выполняются" }).map(([key, title]) => <div key={key} className="rounded-lg border p-4"><p className="text-sm text-muted-foreground">{title}</p><p className="mt-2 text-2xl font-semibold tabular-nums">{query.data ? query.data[key as "calls"] : "—"}</p></div>)}</div><Badge variant={query.data?.enabled ? "success-light" : "warning-light"}>{query.data?.enabled ? "MCP включён" : "MCP выключен"}</Badge><p className="text-sm text-muted-foreground">Выключатель прекращает новые вызовы. Выключатель также останавливает новые решения Route AI. Отдельный профиль можно остановить на вкладке Route AI.</p><PollStorageCapacity capacity={query.error ? {status:"unavailable"} : query.data?.pollStorageCapacity} loading={!query.data && !query.error} refresh={query.refresh} /></Panel>
|
||||
}
|
||||
function Records({ api, kind }: { api: McpApi; kind: "requests" | "plans" | "operations" | "audit" | "drift" }) {
|
||||
const urlParams = useSearchParams(), [selected, setSelected] = useState<RecordRow | null>(null)
|
||||
const search = urlParams.get(kind + ".tool") ?? "", status = urlParams.get(kind + ".status") ?? "", cursor = urlParams.get(kind + ".cursor") ?? ""
|
||||
const setFilter = (key: string, value: string) => { const url = new URL(window.location.href); if (value) url.searchParams.set(kind + "." + key, value); else url.searchParams.delete(kind + "." + key); if (key !== "cursor") url.searchParams.delete(kind + ".cursor"); window.history.replaceState(null, "", url.pathname + url.search) }
|
||||
const setSearch = (value: string) => setFilter("tool", value), setStatus = (value: string) => setFilter("status", value), setCursor = (value: string) => setFilter("cursor", value)
|
||||
const queryFields = new URLSearchParams({ limit: "50" })
|
||||
for (const field of ["tool", "status", "cursor", "connectionId", "serverId", "userId", "from", "to"]) { const value = urlParams.get(kind + "." + field); if (value) queryFields.set(field, value) }
|
||||
const queryKey = queryFields.toString()
|
||||
const load = useCallback((signal: AbortSignal) => api.records(kind, new URLSearchParams(queryKey), signal), [api, kind, queryKey])
|
||||
const query = useQuery(load)
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, RecordRow>[]>(() => [
|
||||
{ accessorKey: "created_at", header: "Время", size: 170, cell: ({ row }) => date(row.original.created_at ?? row.original.createdAt ?? row.original.detected_at) },
|
||||
{ id: "action", header: "Действие", size: 260, cell: ({ row }) => String(row.original.tool ?? row.original.operationId ?? row.original.action ?? row.original.id) },
|
||||
{ id: "state", header: "Результат", cell: ({ row }) => <Badge variant={["completed", "applied"].includes(String(row.original.status ?? row.original.state)) ? "success-light" : "info-light"}>{String(row.original.status ?? row.original.state ?? "событие")}</Badge> },
|
||||
{ accessorKey: "connection_id", header: "Подключение", size: 250, cell: ({ row }) => <span className="font-mono text-xs">{String(row.original.connection_id ?? row.original.connectionId ?? "—")}</span> },
|
||||
{ accessorKey: "error_code", header: "Причина", cell: ({ row }) => String(row.original.error_code ?? row.original.verification ?? "—") },
|
||||
{ id: "details", header: "", cell: ({ row }) => <Button size="sm" variant="ghost" onClick={() => setSelected(row.original)}>Открыть</Button> },
|
||||
], [])
|
||||
const decision = async (approve: boolean) => { if (!selected) return; try { await api.decision(String(selected.id), approve); setSelected(null); query.refresh() } catch (error) { toast.error(errorText(error)) } }
|
||||
return <Panel title={kind === "requests" ? "Обращения" : kind === "plans" ? "Планы изменений" : kind === "operations" ? "Выполнение операций" : kind === "drift" ? "Расхождения конфигурации" : "Административный аудит"} description="Чтение, отказы и изменения доступны для расследования"><ErrorMessage message={query.error} /><p className="text-xs text-muted-foreground">Последнее успешное обновление: {date(query.updatedAt)}{query.error ? " · показаны ранее полученные данные" : ""}</p><RecordFilters kind={kind} /><div className="flex flex-wrap gap-2">{kind === "requests" && <Input className="max-w-sm" aria-label="Имя инструмента" placeholder="Имя инструмента" value={search} onChange={event => { setSearch(event.target.value); setCursor("") }} />}<Input className="max-w-xs" aria-label="Фильтр состояния" placeholder="Состояние" value={status} onChange={event => { setStatus(event.target.value); setCursor("") }} /><Button variant="outline" onClick={query.refresh}><RefreshCwIcon />Обновить</Button></div><Grid items={query.data?.items ?? []} columns={columns} total={query.data?.total} loading={!query.data && !query.error} onSelect={setSelected} /><div className="flex items-center justify-between"><span className="text-sm text-muted-foreground">Найдено: {query.data?.total ?? "—"}</span><div className="flex gap-2"><Button variant="outline" disabled={!cursor} onClick={() => setCursor("")}>В начало</Button><Button variant="outline" disabled={!query.data?.nextCursor} onClick={() => setCursor(query.data?.nextCursor ?? "")}>Далее</Button></div></div><Dialog open={!!selected} onOpenChange={open => { if (!open) setSelected(null) }}><DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-3xl"><DialogHeader><DialogTitle>Детали {kind === "plans" ? "плана" : kind === "operations" ? "операции" : "обращения"}</DialogTitle><DialogDescription>Секретные поля скрыты. Подтверждение относится к этому содержимому.</DialogDescription></DialogHeader>{kind === "plans" && selected ? <PlanReview plan={selected} /> : <pre className="max-h-[55dvh] overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-3 text-xs">{pretty(selected)}</pre>}{kind === "operations" && selected && <ArtifactDownloads key={String(selected.id)} api={api} operation={selected} />}{kind === "plans" && ["approved", "draft"].includes(String(selected?.state)) && can("mm:mcp:admin") && <Button onClick={() => { if (selected) void api.apply(String(selected.id)).then(() => { setSelected(null); query.refresh(); toast.success("Операция принята; результат появится в списке операций") }, error => toast.error(errorText(error))) }}>Применить план</Button>}{kind === "plans" && selected?.state === "awaiting-approval" && can("mm:mcp:approve") && <div className="flex gap-2"><Button onClick={() => { void decision(true) }}>Подтвердить план</Button><Button variant="outline" onClick={() => { void decision(false) }}>Отклонить</Button></div>}</DialogContent></Dialog></Panel>
|
||||
}
|
||||
function RecordFilters({ kind }: { kind: string }) {
|
||||
const params = useSearchParams()
|
||||
const update = (field: string, value: string) => { const url = new URL(window.location.href); if (value) url.searchParams.set(kind + "." + field, value); else url.searchParams.delete(kind + "." + field); url.searchParams.delete(kind + ".cursor"); window.history.replaceState(null, "", url.pathname + url.search) }
|
||||
return <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"><Field label="Фильтр подключения MCP"><Input value={params.get(kind + ".connectionId") ?? ""} placeholder="UUID подключения" onChange={event => update("connectionId", event.target.value)} /></Field>{kind === "requests" && <><Field label="Фильтр ID сервера"><Input type="number" min={1} value={params.get(kind + ".serverId") ?? ""} onChange={event => update("serverId", event.target.value)} /></Field><Field label="Фильтр пользователя"><Input value={params.get(kind + ".userId") ?? ""} onChange={event => update("userId", event.target.value)} /></Field></>}{["from", "to"].map(field => <Field key={field} label={field === "from" ? "Обращения с (UTC)" : "Обращения до (UTC)"}><Input type="datetime-local" value={(params.get(kind + "." + field) ?? "").slice(0, 16)} onChange={event => update(field, event.target.value ? new Date(event.target.value + "Z").toISOString() : "")} /></Field>)}</div>
|
||||
}
|
||||
function Permissions({ api }: { api: McpApi }) {
|
||||
const connections = useQuery(useCallback(signal => api.connections(signal), [api])), protections = useQuery(useCallback(signal => api.protections(signal), [api]))
|
||||
const [connectionId, setConnectionId] = useState(""), [operationId, setOperationId] = useState(""), [serverText, setServerText] = useState(""), [result, setResult] = useState<unknown>(null)
|
||||
const [kind, setKind] = useState("server"), [objectId, setObjectId] = useState(""), [protection, setProtection] = useState("human-approval-required"), [reason, setReason] = useState("")
|
||||
const selected = connections.data?.connections.find(connection => connection.id === connectionId)
|
||||
const preview = async () => { if (!selected) return; try { const serverIds = serverText.split(/[,\s]+/).filter(Boolean).map(Number); const res = await api.preview({ operationId, policy: selected.policy, ownerPermissions: getClaims()?.permissions ?? [], target: serverIds.length ? { scope: "servers", serverIds } : { scope: "global" }, objects: [], protections: protections.data?.protections.map(item => item.object_ref) ?? [] }); setResult(res.result) } catch (error) { toast.error(errorText(error)) } }
|
||||
return <div className="flex flex-col gap-4"><Panel title="Проверка разрешений" description="Гипотетическое решение без выполнения действий и резервирования бюджета"><ErrorMessage message={connections.error || protections.error} /><div className="grid gap-3 md:grid-cols-3"><NativeSelect label="Подключение" value={connectionId} onChange={setConnectionId}><option value="">Выберите подключение</option>{connections.data?.connections.map(connection => <option key={connection.id} value={connection.id}>{connection.name}</option>)}</NativeSelect><NativeSelect label="Операция" value={operationId} onChange={setOperationId}><option value="">Выберите действие</option>{selected?.policy.scope.operationIds.map(id => <option key={id}>{id}</option>)}</NativeSelect><Field label="ID серверов; пусто — глобальное действие"><Input value={serverText} onChange={event => setServerText(event.target.value)} /></Field></div><Button className="self-start" disabled={!selected || !operationId || !can("mm:mcp:admin")} onClick={() => { void preview() }}><ShieldIcon />Проверить</Button>{result !== null && <pre role="status" className="overflow-auto whitespace-pre-wrap rounded bg-muted p-3 text-xs">{pretty(result)}</pre>}</Panel><Panel title="Защищённые объекты" description="Защита сохраняется и при автономном доступе"><div className="flex flex-col gap-2">{protections.data?.protections.map(item => <div key={item.id} className="flex flex-wrap items-center justify-between gap-2 rounded border p-3 text-sm"><span>{item.object_ref.kind} · {item.object_ref.id} · {item.object_ref.protection}<span className="ml-2 text-muted-foreground">{item.reason}</span></span>{can("mm:mcp:admin") && <Button variant="outline" size="sm" onClick={() => { if (window.confirm("Удалить защиту объекта?")) void api.removeProtection(item.id).then(protections.refresh, error => toast.error(errorText(error))) }}>Удалить защиту</Button>}</div>)}</div>{can("mm:mcp:admin") && <><div className="grid gap-3 md:grid-cols-4"><Field label="Тип объекта"><Input value={kind} onChange={event => setKind(event.target.value)} /></Field><Field label="Стабильный ID"><Input value={objectId} onChange={event => setObjectId(event.target.value)} /></Field><NativeSelect label="Защита" value={protection} onChange={setProtection}><option value="human-approval-required">Подтверждение человеком</option><option value="deny">Запрет изменений</option></NativeSelect><Field label="Причина"><Input value={reason} onChange={event => setReason(event.target.value)} /></Field></div><Button className="self-start" disabled={!objectId} onClick={() => { void api.protect({ kind, id: objectId, protection, ...(kind === "server" ? {} : { serverId: Number(serverText) }) }, reason).then(protections.refresh, error => toast.error(errorText(error))) }}>Добавить защиту</Button></>}</Panel></div>
|
||||
}
|
||||
function OperationRecovery({ api }: { api: McpApi }) {
|
||||
const [id, setId] = useState(""), [result, setResult] = useState<unknown>(null), [error, setError] = useState(""), [busy, setBusy] = useState(false)
|
||||
const run = async (recover: boolean) => {
|
||||
setBusy(true); setError(""); setResult(null)
|
||||
try { setResult(recover ? await api.recoverOperation(id) : await api.reconcileOperation(id)) } catch (failure) { setError(errorText(failure)) } finally { setBusy(false) }
|
||||
}
|
||||
return <Panel title="Сверка и восстановление" description="Сверка читает состояние устройства. Восстановление создаёт отдельный план и проверяет отсутствие внешних изменений."><ErrorMessage message={error} /><Field label="ID исходной операции"><Input value={id} onChange={event => { setId(event.target.value); setResult(null) }} /></Field><div className="flex flex-wrap gap-2"><Button variant="outline" disabled={busy || !id} onClick={() => { void run(false) }}>Сверить состояние операции</Button>{can("mm:mcp:admin") && <Button disabled={busy || !id} onClick={() => { void run(true) }}>Подготовить обратный план</Button>}</div>{result !== null && <pre role="status" className="max-h-60 overflow-auto whitespace-pre-wrap break-all text-xs">{pretty(result)}</pre>}</Panel>
|
||||
}
|
||||
function ManagementChannels({ api }: { api: McpApi }) {
|
||||
const channels = useQuery(useCallback(signal => api.managementChannels(signal), [api]))
|
||||
const [serverId, setServerId] = useState(""), [host, setHost] = useState(""), [port, setPort] = useState(443), [ssl, setSsl] = useState(true), [username, setUsername] = useState(""), [password, setPassword] = useState(""), [attestation, setAttestation] = useState("")
|
||||
const [expiry, setExpiry] = useState(() => new Date(Date.now() + 1800000).toISOString().slice(0, 16)), [busy, setBusy] = useState(false), [error, setError] = useState("")
|
||||
const register = async () => {
|
||||
setBusy(true); setError("")
|
||||
try { await api.registerManagement({ serverId: Number(serverId), host, port, useSsl: ssl, verifySsl: true, username, password, attestation, expiresAt: new Date(expiry + "Z").toISOString() }); setPassword(""); channels.refresh(); toast.success("Канал проверен по идентичности RouterOS и зарегистрирован") } catch (failure) { setError(errorText(failure)) } finally { setBusy(false) }
|
||||
}
|
||||
return <Panel title="Независимый канал управления" description="Другой IP того же RouterOS. Backend проверяет стабильный ID и доступность; независимость сетевого пути подтверждает администратор."><ErrorMessage message={channels.error || error} /><Grid items={channels.data?.channels ?? []} columns={[{ accessorKey: "server_id", header: "Сервер" }, { accessorKey: "verified_at", header: "Проверен", cell: ({ row }) => date(row.original.verified_at) }, { accessorKey: "expires_at", header: "Допуск до", cell: ({ row }) => date(row.original.expires_at) }, { id: "action", header: "Доступ", cell: ({ row }) => row.original.revoked_at ? "Отозван" : <Button size="sm" variant="outline" onClick={() => { void api.revokeManagement(String(row.original.id)).then(channels.refresh, failure => toast.error(errorText(failure))) }}>Отозвать канал</Button> }]} />{can("mm:network:admin") && <><div className="grid gap-3 sm:grid-cols-2"><Field label="ID сервера независимого канала"><Input type="number" min={1} value={serverId} onChange={event => setServerId(event.target.value)} /></Field><Field label="IP независимого управления"><Input value={host} onChange={event => setHost(event.target.value)} /></Field><Field label="Порт независимого управления"><Input type="number" min={1} max={65535} value={port} onChange={event => setPort(Number(event.target.value))} /></Field><Choice label="HTTPS с проверкой сертификата" checked={ssl} onChange={setSsl} /><Field label="Пользователь независимого канала"><Input autoComplete="off" value={username} onChange={event => setUsername(event.target.value)} /></Field><Field label="Пароль независимого канала"><Input type="password" autoComplete="new-password" value={password} onChange={event => setPassword(event.target.value)} /></Field><Field label="Допуск независимого канала до (UTC)"><Input type="datetime-local" value={expiry} onChange={event => setExpiry(event.target.value)} /></Field><Field label="Подтверждение независимости пути"><Textarea value={attestation} onChange={event => setAttestation(event.target.value)} placeholder="Опишите отдельный путь, интерфейс/VRF и почему изменение основного пути его не затронет" /></Field></div><Button disabled={busy || !serverId || !host || !username || !password || attestation.trim().length < 30} onClick={() => { void register() }}>Проверить и зарегистрировать канал</Button></>}</Panel>
|
||||
}
|
||||
function Settings({ api }: { api: McpApi }) {
|
||||
const query = useQuery(useCallback(signal => api.settings(signal), [api])), clients = useQuery(useCallback(signal => api.clients(signal), [api]))
|
||||
const [name, setName] = useState(""), [redirect, setRedirect] = useState("")
|
||||
const save = async (value: unknown) => { try { await api.saveSettings(value); query.refresh() } catch (error) { toast.error(errorText(error)) } }
|
||||
return <div className="flex flex-col gap-4"><Panel title="Настройки MCP" description="По умолчанию внешний доступ выключен"><ErrorMessage message={query.error} />{query.data && <><Choice label="Разрешить обращения MCP" checked={query.data.enabled} onChange={enabled => { void save({ enabled }) }} /><div className="grid gap-3 sm:grid-cols-2"><Field label="Хранение обращений, дней"><Input type="number" min={1} max={365} defaultValue={query.data.requestsRetentionDays} onBlur={event => { const value = Number(event.target.value); if (value !== query.data?.requestsRetentionDays && window.confirm("Изменить срок хранения обращений? Старые записи будут удаляться по новому сроку.")) void save({ requestsRetentionDays: value }) }} /></Field><Field label="Хранение аудита, дней"><Input type="number" min={30} max={3650} defaultValue={query.data.auditRetentionDays} onBlur={event => { const value = Number(event.target.value); if (value !== query.data?.auditRetentionDays && window.confirm("Изменить срок хранения аудита?")) void save({ auditRetentionDays: value }) }} /></Field></div></>}</Panel><Panel title="OAuth-клиенты" description="Зарегистрируйте точный redirect URI внешнего AI-клиента"><ErrorMessage message={clients.error} />{clients.data?.clients.map(client => <div key={client.id} className="rounded border p-3 text-sm"><p className="font-medium">{client.name} · {client.enabled ? "разрешён" : "отключён"}</p><p className="break-all font-mono text-xs">client_id: {client.id}</p><p className="break-all text-muted-foreground">{client.redirect_uris.join(", ")}</p>{client.enabled && <Button size="sm" variant="outline" onClick={() => { if (window.confirm("Отключить OAuth-клиент и отозвать его токены?")) void api.disableClient(client.id).then(clients.refresh, error => toast.error(errorText(error))) }}>Отключить и отозвать токены</Button>}</div>)}<Field label="Название клиента"><Input value={name} onChange={event => setName(event.target.value)} /></Field><Field label="Redirect URI, по одному на строку"><Textarea value={redirect} onChange={event => setRedirect(event.target.value)} /></Field><Button className="self-start" disabled={!name || !redirect} onClick={() => { void api.createClient(name, redirect.split("\n").map(value => value.trim()).filter(Boolean)).then(() => { setName(""); setRedirect(""); clients.refresh() }, error => toast.error(errorText(error))) }}>Зарегистрировать клиент</Button></Panel><Records api={api} kind="audit" /></div>
|
||||
}
|
||||
function RestCoverage({ api }: { api: McpApi }) {
|
||||
const query = useQuery(useCallback(signal => api.coverage(signal), [api])), routes = query.data?.routes ?? []
|
||||
return <Panel title="Покрытие REST через MCP" description="Сервисные сценарии проверяются отдельно от количества инструментов. Новые endpoints отслеживает автоматическая проверка реестра."><ErrorMessage message={query.error} /><div className="flex flex-wrap gap-2"><Badge variant="success-light">Сценариев с MCP: {routes.filter(route => route.status === "mediated").length}</Badge><Badge variant="info-light">Транспорт и управление доступом: {routes.filter(route => route.status === "excluded").length}</Badge><Badge variant="warning-light">Ожидают реализации: {routes.filter(route => route.status === "pending").length}</Badge></div><Grid items={routes.filter(route => route.status === "pending")} columns={[{ accessorKey: "method", header: "Метод", size: 90 }, { accessorKey: "path", header: "Незавершённый сценарий", size: 310 }, { accessorKey: "reason", header: "Что осталось", size: 400 }]} loading={!query.data && !query.error} /></Panel>
|
||||
}
|
||||
function Consent({ api, id }: { api: McpApi; id: string }) {
|
||||
const request = useQuery(useCallback(signal => api.consentRequest(id, signal), [api, id])), connections = useQuery(useCallback(signal => api.connections(signal), [api]))
|
||||
const [selected, setSelected] = useState("")
|
||||
const confirm = async (approve: boolean) => { try { const result = await api.consent(id, selected, approve); window.location.assign(result.redirectUrl) } catch (error) { toast.error(errorText(error)) } }
|
||||
return <Panel title="Разрешение OAuth-доступа" description="Выберите подключение с нужными ограничениями; клиент получит только его доступ"><ErrorMessage message={request.error || connections.error} /><p>{request.data?.name} запрашивает доступ к MikrotikManager.</p><p className="break-all text-sm text-muted-foreground">Возврат: {request.data?.redirect_uri}</p><NativeSelect label="Подключение" value={selected} onChange={setSelected}><option value="">Выберите ограниченный доступ</option>{connections.data?.connections.filter(connection => connection.ownerId === getClaims()?.sub && connection.policy.status === "active").map(connection => <option key={connection.id} value={connection.id}>{connection.name} · {modeLabels[connection.policy.mode]}</option>)}</NativeSelect><div className="flex gap-2"><Button disabled={!selected} onClick={() => { void confirm(true) }}>Разрешить</Button><Button variant="outline" disabled={!selected} onClick={() => { void confirm(false) }}>Отклонить</Button></div></Panel>
|
||||
}
|
||||
export function McpWorkspace() {
|
||||
const { backendUrl, mode } = useDataSource()
|
||||
return <McpWorkspaceContent key={JSON.stringify([backendUrl, mode])} backendUrl={backendUrl} live={mode === "live"} />
|
||||
}
|
||||
function McpWorkspaceContent({ backendUrl, live }: { backendUrl: string; live: boolean }) {
|
||||
const params = useSearchParams(), initial = params.get("tab") as Tab | null
|
||||
const [tab, setTab] = useState<Tab>(initial && initial in tabs ? initial : "overview")
|
||||
const api = useMemo(() => mcpApi(backendUrl), [backendUrl])
|
||||
const [operations, setOperations] = useState<McpOperationDescriptor[]>([]), [error, setError] = useState("")
|
||||
useEffect(() => { if (!live) return; const controller = new AbortController(); void api.catalog(controller.signal).then(catalog => setOperations(catalog.operations), failure => { if (!controller.signal.aborted) setError(errorText(failure)) }); return () => controller.abort() }, [api, live])
|
||||
const changeTab = (value: unknown) => { const next = String(value) as Tab; setTab(next); const url = new URL(window.location.href); url.searchParams.set("tab", next); window.history.replaceState(null, "", url.pathname + url.search) }
|
||||
return <><PageHeader crumbs={[{ label: "MCP" }]} /><main className="flex min-w-0 flex-1 flex-col gap-5 p-4 md:p-6"><div className="flex items-center gap-3"><BotIcon className="size-7 text-muted-foreground" /><div><h1 className="text-xl font-semibold">MCP</h1><p className="text-sm text-muted-foreground">Доступ нейросетей, разрешения и контроль изменений</p></div></div>{!live ? <Panel title="Подключите backend"><p>Для выдачи реального AI-доступа выберите режим «Живые» и настройте авторизацию портала.</p></Panel> : <><ErrorMessage message={error} />{params.get("oauthRequest") && <Consent api={api} id={params.get("oauthRequest")!} />}<Tabs value={tab} onValueChange={changeTab}><div className="overflow-x-auto pb-1"><TabsList variant="line">{Object.entries(tabs).filter(([key]) => key !== "settings" || can("mm:mcp:admin")).map(([key, title]) => <TabsTrigger key={key} value={key}>{title}</TabsTrigger>)}</TabsList></div><TabsContent value="overview"><Overview api={api} /></TabsContent><TabsContent value="connections"><div className="flex flex-col gap-4"><Connections api={api} operations={operations} />{can("mm:mcp:admin") && <AccessTools api={api} />}</div></TabsContent><TabsContent value="permissions"><Permissions api={api} /></TabsContent><TabsContent value="devices"><DeviceManagement api={api} /></TabsContent><TabsContent value="routing"><div className="flex flex-col gap-4"><RoutingProfiles api={api} /><RoutePathVerification api={api} /></div></TabsContent><TabsContent value="requests"><Records api={api} kind="requests" /></TabsContent><TabsContent value="plans"><div className="flex flex-col gap-4"><Records api={api} kind="plans" /><Records api={api} kind="operations" /><OperationRecovery api={api} /><Records api={api} kind="drift" /></div></TabsContent>{can("mm:mcp:admin") && <TabsContent value="settings"><div className="flex flex-col gap-4"><Settings api={api} /><RestCoverage api={api} /><ManagementChannels api={api} />{can("mm:settings:admin") && <DatabaseRestore api={api} />}</div></TabsContent>}</Tabs></>}</main></>
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
const object = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
||||
const list = (value: unknown): unknown[] => Array.isArray(value) ? value : []
|
||||
const json = (value: unknown) => JSON.stringify(value, null, 2)
|
||||
export function PlanReview({ plan }: { plan: Record<string, unknown> }) {
|
||||
const changes = object(plan.changes), target = object(changes.target)
|
||||
const objects = list(changes.objects), steps = list(changes.steps), recovery = object(changes.recovery)
|
||||
return <div className="flex min-w-0 flex-col gap-4">
|
||||
<div className="flex flex-wrap gap-2"><Badge variant="info-light">{String(plan.operationId ?? "Операция")}</Badge><Badge variant="warning-light">{String(plan.state ?? "Неизвестно")}</Badge><Badge variant="invert-light">Шагов: {steps.length}</Badge></div>
|
||||
<dl className="grid gap-2 text-sm sm:grid-cols-2"><div><dt className="text-muted-foreground">Область</dt><dd>{target.scope === "servers" ? "Серверы: " + list(target.serverIds).join(", ") : target.scope === "global" ? "Всё приложение" : "Подключение"}</dd></div><div><dt className="text-muted-foreground">Срок плана</dt><dd>{plan.expiresAt ? new Date(String(plan.expiresAt)).toLocaleString("ru-RU") : "Не указан"}</dd></div></dl>
|
||||
{!!list(changes.warnings).length && <div className="rounded-md border border-warning/30 p-3 text-sm"><p className="mb-2 font-medium">Условия и последствия</p><ul className="list-disc space-y-1 pl-5">{list(changes.warnings).map((warning, index) => <li key={index}>{String(warning)}</li>)}</ul></div>}
|
||||
{!!objects.length && <div><h3 className="mb-2 text-sm font-medium">Объекты и защищённые зависимости</h3><div className="flex flex-wrap gap-2">{objects.map((value, index) => { const item = object(value); return <Badge key={index} variant="invert-light">{item.serverId ? `Сервер ${item.serverId} · ` : ""}{String(item.kind)} · {String(item.id)}</Badge> })}</div></div>}
|
||||
<div className="grid min-w-0 gap-3 md:grid-cols-2"><div className="min-w-0"><h3 className="mb-2 text-sm font-medium">Исходное состояние</h3><pre className="max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-3 text-xs">{json(changes.before ?? changes.snapshots ?? null)}</pre></div><div className="min-w-0"><h3 className="mb-2 text-sm font-medium">Запланированные шаги</h3><ol className="flex flex-col gap-2">{steps.map((value, index) => { const step = object(value); return <li key={index} className="min-w-0 rounded-md border p-3"><p className="mb-2 text-sm font-medium">{index + 1}. {step.kind === "ros" ? String(object(step.step).action) + " · " + String(object(step.step).resource) : String(step.service)}</p><pre className="max-h-52 overflow-auto whitespace-pre-wrap break-all text-xs">{json(step.kind === "ros" ? step.step : step.input)}</pre></li> })}</ol></div></div>
|
||||
{!!Object.keys(recovery).length && <p className="rounded-md border p-3 text-sm">Зарезервировано восстановление до {new Date(String(recovery.validUntil)).toLocaleString("ru-RU")}. Оно выполняется только при подтверждённой исходной записи, неуспешной проверке и действующем допуске; неопределённую запись исполнитель не повторяет.</p>}
|
||||
<details><summary className="cursor-pointer text-sm text-muted-foreground">Полные данные плана</summary><pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap break-all text-xs">{json(plan)}</pre></details>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type {McpPollStorageCapacity} from "@mmapp/contracts/mcp"
|
||||
import {Badge} from "@/components/reui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
|
||||
export function PollStorageCapacity({capacity,loading,refresh}:{capacity?:McpPollStorageCapacity;loading:boolean;refresh:()=>void}){
|
||||
const observed=capacity?.status==="observed"?capacity:undefined
|
||||
const ownerFull=observed?.owner.remaining===0,globalFull=observed?.global.exhausted===true
|
||||
const full=ownerFull||globalFull
|
||||
return <section aria-label="Журнал полных опросов" className="flex flex-col gap-3 rounded-lg border p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="font-medium">Журнал полных опросов</h3>
|
||||
<div className="flex items-center gap-2"><Badge variant={!observed?"info-light":full?"warning-light":"success-light"}>{loading?"Загрузка":!observed?"Нет данных":full?"Квота заполнена":"Квота не заполнена"}</Badge><Button size="sm" variant="outline" onClick={refresh}>Обновить статус</Button></div>
|
||||
</div>
|
||||
{observed?<>
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div><dt className="text-muted-foreground">Ваши полные журналы</dt><dd className="font-medium tabular-nums">{observed.owner.used} / {observed.owner.limit}<span className="ml-2 font-normal text-muted-foreground">Осталось: {observed.owner.remaining}</span></dd></div>
|
||||
<div><dt className="text-muted-foreground">Полные журналы приложения</dt><dd className="font-medium tabular-nums">{observed.global.usage?<>{observed.global.usage.used} / {observed.global.usage.limit}<span className="ml-2 font-normal text-muted-foreground">Осталось: {observed.global.usage.remaining}</span></>:globalFull?"Заполнена":"Не заполнена"}</dd></div>
|
||||
</dl>
|
||||
{full&&<p role="alert" className="text-sm">{ownerFull&&globalFull?"Личная и общая квоты журнала заполнены.":ownerFull?"Личная квота журнала заполнена.":"Общая квота журнала заполнена."} Новый полный опрос не получит место. После безопасного архивирования или очистки по сроку хранения место может освободиться. Неизвестный результат сохраняет резерв; повторные попытки его не освобождают.</p>}
|
||||
<p className="text-sm text-muted-foreground">Счётчик учитывает резерв под выполнение и журналы, ожидающие архивирования. Архив сохраняет все исходные записи и продолжает занимать место. Поэтому остаток зависит и от объёма сохранённой истории.</p>
|
||||
<p className="text-sm text-muted-foreground">Срок хранения новых записей: {observed.retentionDays} дн. Существующие записи могут храниться дольше. Архивирование сохраняет исходный срок; очистка учитывает завершение операции, истечение допуска и защитный интервал.</p>
|
||||
<p className="text-xs text-muted-foreground">Состояние показано на момент обновления. Окончательный допуск проверяется при запуске.</p>
|
||||
</>:<p role="status" className="text-sm text-muted-foreground">{loading?"Проверяем заполнение журнала…":"Заполнение журнала сейчас недоступно. Свободное место не подтверждено; обновите статус позже."}</p>}
|
||||
</section>
|
||||
}
|
||||
Reference in new issue
Block a user