@@ -1,11 +1,12 @@
"use client"
"use client"
import { useCallback , useEffect , useMemo , useRef , useState , type ReactNode } from "react"
import { useCallback , useEffect , useMemo , useRef , useState , type ReactNode } from "react"
import Link from "next/link"
import { useSearchParams } from "next/navigation"
import { useSearchParams } from "next/navigation"
import { useTable , type ColumnDef } from "@tanstack/react-table"
import { useTable , type ColumnDef } from "@tanstack/react-table"
import { BotIcon , CopyIcon , PlusIcon , RefreshCwIcon , ShieldIcon , StopCircleIcon } from "lucide-react"
import { BotIcon , CopyIcon , PlusIcon , RefreshCwIcon , ShieldIcon , StopCircleIcon } from "lucide-react"
import { toast } from "sonner"
import { toast } from "sonner"
import { mcpConnectionCreateSchema , mcpTargetSchema , type McpConnection , type McpOperationDescriptor , type McpPolicyDecision } from "@mmapp/contracts/mcp"
import { mcpConnectionCreateSchema , mcpPageQuerySchema , mcpTargetSchema , type McpConnection , type McpOperationDescriptor , type McpPolicyDecision } from "@mmapp/contracts/mcp"
import { routeAiControllerBlockReason , routeAiProfileSchema , type RouteAiProfile } from "@mmapp/contracts/mcp-routing"
import { routeAiControllerBlockReason , routeAiProfileSchema , type RouteAiProfile } from "@mmapp/contracts/mcp-routing"
import { useDataSource } from "@/lib/data-source"
import { useDataSource } from "@/lib/data-source"
import { can , getClaims , hasPermission } from "@/lib/auth"
import { can , getClaims , hasPermission } from "@/lib/auth"
@@ -21,7 +22,7 @@ import { ObjectPermissionPreview, PolicyPreviewDecision } from "./object-permiss
import { Frame , FrameHeader , FrameTitle , FrameDescription , FramePanel } from "@/components/reui/frame"
import { Frame , FrameHeader , FrameTitle , FrameDescription , FramePanel } from "@/components/reui/frame"
import { DataGrid , DataGridContainer , DataGridTable , DataGridScrollArea , dataGridFeatures , type DataGridFeatures } from "@/components/reui/data-grid"
import { DataGrid , DataGridContainer , DataGridTable , DataGridScrollArea , dataGridFeatures , type DataGridFeatures } from "@/components/reui/data-grid"
import { Badge } from "@/components/reui/badge"
import { Badge } from "@/components/reui/badge"
import { Button } from "@/components/ui/button"
import { Button , buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Input } from "@/components/ui/input"
import { Checkbox } from "@/components/ui/checkbox"
import { Checkbox } from "@/components/ui/checkbox"
import { Textarea } from "@/components/ui/textarea"
import { Textarea } from "@/components/ui/textarea"
@@ -393,6 +394,10 @@ const recordQueryFilters = {
function Records ( { api , kind } : { api : McpApi ; kind : keyof typeof recordQueryFilters } ) {
function Records ( { api , kind } : { api : McpApi ; kind : keyof typeof recordQueryFilters } ) {
const urlParams = useSearchParams ( ) , [ selected , setSelected ] = useState < RecordRow | null > ( null )
const urlParams = useSearchParams ( ) , [ selected , setSelected ] = useState < RecordRow | null > ( null )
const [ startedOperation , setStartedOperation ] = useState < string | null > ( null )
const operationId = kind === "operations" ? urlParams . get ( "operation" ) : null
const operationLink = new URLSearchParams ( urlParams )
if ( startedOperation ) operationLink . set ( "operation" , startedOperation )
const fields : readonly string [ ] = recordQueryFilters [ kind ]
const fields : readonly string [ ] = recordQueryFilters [ kind ]
const filterValue = ( field : string ) = > urlParams . get ( kind + "." + field ) ? ? ""
const filterValue = ( field : string ) = > urlParams . get ( kind + "." + field ) ? ? ""
const cursor = filterValue ( "cursor" )
const cursor = filterValue ( "cursor" )
@@ -405,19 +410,53 @@ function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFil
const queryKey = queryFields . toString ( )
const queryKey = queryFields . toString ( )
const load = useCallback ( ( signal : AbortSignal ) = > api . records ( kind , new URLSearchParams ( queryKey ) , signal ) , [ api , kind , queryKey ] )
const load = useCallback ( ( signal : AbortSignal ) = > api . records ( kind , new URLSearchParams ( queryKey ) , signal ) , [ api , kind , queryKey ] )
const query = useQuery ( load , ` ${ kind } ? ${ queryKey } ` )
const query = useQuery ( load , ` ${ kind } ? ${ queryKey } ` )
const journalConnection = kind === "requests" ? filterValue ( "connectionId" ) : ""
const navigationClass = buttonVariants ( { variant : "outline" , className : "self-start" } )
const selectRecord = useCallback ( ( row : RecordRow ) = > {
if ( kind !== "operations" ) { setSelected ( row ) ; return }
const url = new URL ( window . location . href )
url . searchParams . set ( "operation" , String ( row . id ) )
window . history . pushState ( null , "" , url . pathname + url . search )
} , [ kind ] )
const columns = useMemo < ColumnDef < DataGridFeatures , RecordRow > [ ] > ( ( ) = > [
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 ) } ,
{ 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 : "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 > } ,
{ 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 : "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 ? ? "—" ) } ,
{ 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 > } ,
{ id : "details" , header : "" , cell : ( { row } ) = > < Button size = "sm" variant = "ghost" onClick = { ( ) = > selectRecord ( row . original ) } > О т к р ы т ь < / Button > } ,
] , [ ] )
] , [ selectRecord ] )
const pendingPlanRequests = useRef ( new Set < string > ( ) )
const pendingPlanRequests = useRef ( new Set < string > ( ) )
const [ pendingPlans , setPendingPlans ] = useState < ReadonlySet < string > > ( ( ) = > new Set ( ) )
const [ pendingPlans , setPendingPlans ] = useState < ReadonlySet < string > > ( ( ) = > new Set ( ) )
const selectedPlanPending = kind === "plans" && ! ! selected && pendingPlans . has ( String ( selected . id ) )
const selectedPlanPending = kind === "plans" && ! ! selected && pendingPlans . has ( String ( selected . id ) )
const planExpiry = kind === "plans" && typeof selected ? . expiresAt === "string" ? Date . parse ( selected . expiresAt ) : null
const [ planClock , setPlanClock ] = useState ( 0 )
useEffect ( ( ) = > {
if ( planExpiry === null || ! Number . isFinite ( planExpiry ) ) return
let timer : ReturnType < typeof setTimeout >
const update = ( ) = > {
clearTimeout ( timer )
const now = Date . now ( )
setPlanClock ( now )
if ( now < planExpiry ) timer = setTimeout ( update , Math . min ( planExpiry - now , 2 _147_483_647 ) )
}
update ( )
document . addEventListener ( "visibilitychange" , update )
return ( ) = > {
clearTimeout ( timer )
document . removeEventListener ( "visibilitychange" , update )
}
} , [ planExpiry ] )
const planExpired = planExpiry !== null && ( ! Number . isFinite ( planExpiry ) || planExpiry <= planClock )
const currentPlan = kind === "plans" && selected ? query . data ? . items . find ( item = > item . id === selected . id ) : null
const planChanged = kind === "plans" && ! ! selected && ( ! currentPlan || query . error || pretty ( currentPlan ) !== pretty ( selected ) )
const planActionDisabled = selectedPlanPending || planExpired || ! ! planChanged
const submitPlan = async ( action : "approve" | "reject" | "apply" ) = > {
const submitPlan = async ( action : "approve" | "reject" | "apply" ) = > {
if ( ! selected ) return
if ( ! selected || planChanged ) return
if ( planExpiry !== null && ( ! Number . isFinite ( planExpiry ) || planExpiry <= Date . now ( ) ) ) {
setPlanClock ( Date . now ( ) )
return
}
const plan = selected
const plan = selected
const planId = String ( plan . id )
const planId = String ( plan . id )
const pending = pendingPlanRequests . current
const pending = pendingPlanRequests . current
@@ -426,8 +465,9 @@ function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFil
setPendingPlans ( new Set ( pending ) )
setPendingPlans ( new Set ( pending ) )
try {
try {
if ( action === "apply" ) {
if ( action === "apply" ) {
await api . apply ( planId )
const operation = await api . apply ( planId )
toast . success ( "Операция принята; результат появится в списке операций" )
setStartedOperation ( operation . id )
toast . success ( "Запрос принят. Откройте выполнение операции для проверки результата." )
} else {
} else {
await api . decision ( planId , action === "approve" )
await api . decision ( planId , action === "approve" )
}
}
@@ -440,9 +480,20 @@ function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFil
setPendingPlans ( new Set ( pending ) )
setPendingPlans ( new Set ( pending ) )
}
}
}
}
const closeDetails = ( ) = > {
setSelected ( null )
if ( operationId ) {
const url = new URL ( window . location . href )
url . searchParams . delete ( "operation" )
window . history . replaceState ( null , "" , url . pathname + url . search )
}
}
return (
return (
< Panel title = { kind === "requests" ? "Обращения" : kind === "plans" ? "Планы изменений" : kind === "operations" ? "Выполнение операций" : kind === "drift" ? "Расхождения конфигурации" : "Административный аудит" } description = "Чтение, отказы и изменения доступны для расследования" >
< Panel title = { kind === "requests" ? "Обращения" : kind === "plans" ? "Планы изменений" : kind === "operations" ? "Выполнение операций" : kind === "drift" ? "Расхождения конфигурации" : "Административный аудит" } description = "Чтение, отказы и изменения доступны для расследования" >
< ErrorMessage message = { query . error } / >
< ErrorMessage message = { query . error } / >
{ startedOperation && < Link className = { navigationClass } href = { ` /mcp? ${ operationLink } ` } > О т к р ы т ь в ы п о л н е н и е о п е р а ц и и < / Link > }
{ journalConnection && < Link className = { navigationClass } href = { ` /mcp? ${ new URLSearchParams ( { tab : "plans" , "plans.connectionId" : journalConnection , "operations.connectionId" : journalConnection } )} ` } > П л а н ы и о п е р а ц и и п о д к л ю ч е н и я < / Link > }
{ journalConnection && can ( "mm:mcp:admin" ) && < Link className = { navigationClass } href = { ` /mcp? ${ new URLSearchParams ( { tab : "settings" , "audit.connectionId" : journalConnection } )}#mcp-audit ` } > А д м и н и с т р а т и в н ы й а у д и т п о д к л ю ч е н и я < / Link > }
< p className = "text-xs text-muted-foreground" > П о с л е д н е е у с п е ш н о е о б н о в л е н и е : { date ( query . updatedAt ) } { query . error && query . data ? " · показаны ранее полученные данные" : "" } < / p >
< p className = "text-xs text-muted-foreground" > П о с л е д н е е у с п е ш н о е о б н о в л е н и е : { date ( query . updatedAt ) } { query . error && query . data ? " · показаны ранее полученные данные" : "" } < / p >
< RecordFilters fields = { fields } value = { filterValue } update = { setFilter } / >
< RecordFilters fields = { fields } value = { filterValue } update = { setFilter } / >
< div className = "flex flex-wrap gap-2" >
< div className = "flex flex-wrap gap-2" >
@@ -450,7 +501,7 @@ function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFil
{ fields . includes ( "status" ) && < Input className = "max-w-xs" aria-label = "Фильтр состояния" placeholder = "Состояние" value = { filterValue ( "status" ) } onChange = { event = > setFilter ( "status" , event . target . value ) } / > }
{ fields . includes ( "status" ) && < Input className = "max-w-xs" aria-label = "Фильтр состояния" placeholder = "Состояние" value = { filterValue ( "status" ) } onChange = { event = > setFilter ( "status" , event . target . value ) } / > }
< Button variant = "outline" onClick = { query . refresh } > < RefreshCwIcon / > О б н о в и т ь < / Button >
< Button variant = "outline" onClick = { query . refresh } > < RefreshCwIcon / > О б н о в и т ь < / Button >
< / div >
< / div >
< Grid items = { query . data ? . items ? ? [ ] } columns = { columns } total = { query . data ? . total } loading = { ! query . data && ! query . error } onSelect = { setSelected } / >
< Grid items = { query . data ? . items ? ? [ ] } columns = { columns } total = { query . data ? . total } loading = { ! query . data && ! query . error } onSelect = { selectRecord } / >
< div className = "flex items-center justify-between" >
< div className = "flex items-center justify-between" >
< span className = "text-sm text-muted-foreground" > Н а й д е н о : { query . data ? . total ? ? "—" } < / span >
< span className = "text-sm text-muted-foreground" > Н а й д е н о : { query . data ? . total ? ? "—" } < / span >
< div className = "flex gap-2" >
< div className = "flex gap-2" >
@@ -458,10 +509,65 @@ function Records({ api, kind }: { api: McpApi; kind: keyof typeof recordQueryFil
< Button variant = "outline" disabled = { ! query . data ? . nextCursor } onClick = { ( ) = > setFilter ( "cursor" , query . data ? . nextCursor ? ? "" ) } > Д а л е е < / Button >
< Button variant = "outline" disabled = { ! query . data ? . nextCursor } onClick = { ( ) = > setFilter ( "cursor" , query . data ? . nextCursor ? ? "" ) } > Д а л е е < / Button >
< / div >
< / div >
< / 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 >
< Dialog open = { ! ! selected || ! ! operationId } onOpenChange = { open = > { if ( ! open ) closeDetails ( ) } } >
< DialogContent className = "max-h-[90dvh] overflow-y-auto sm:max-w-3xl" >
< DialogHeader >
< DialogTitle > Д е т а л и { kind === "plans" ? "плана" : kind === "operations" ? "операции" : "обращения" } < / DialogTitle >
< DialogDescription > { kind === "plans" ? "Подтверждение относится к показанному плану." : "Данные журнала с маскировкой секретных полей." } < / DialogDescription >
< / DialogHeader >
{ operationId ? < OperationMonitor key = { operationId } api = { api } id = { operationId } / > : 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 > }
{ planExpired && < p role = "status" className = "text-sm text-muted-foreground" > С р о к п л а н а и с т ё к . П о д г о т о в ь т е н о в ы й п л а н . < / p > }
{ planChanged && ! planExpired && < p role = "status" className = "text-sm text-muted-foreground" > Т е к у щ а я в е р с и я п л а н а н е д о с т у п н а и л и и з м е н и л а с ь . О т к р о й т е п л а н з а н о в о . < / p > }
{ kind === "plans" && [ "approved" , "draft" ] . includes ( String ( selected ? . state ) ) && can ( "mm:mcp:admin" ) && < Button disabled = { planActionDisabled } onClick = { ( ) = > { void submitPlan ( "apply" ) } } > П р и м е н и т ь п л а н < / Button > }
{ kind === "plans" && selected ? . state === "awaiting-approval" && can ( "mm:mcp:approve" ) && < div className = "flex gap-2" > < Button disabled = { planActionDisabled } onClick = { ( ) = > { void submitPlan ( "approve" ) } } > П о д т в е р д и т ь п л а н < / Button > < Button variant = "outline" disabled = { planActionDisabled } onClick = { ( ) = > { void submitPlan ( "reject" ) } } > О т к л о н и т ь < / Button > < / div > }
< / DialogContent >
< / Dialog >
< / Panel >
< / Panel >
)
)
}
}
const operationStates : Record < string , string > = {
pending : "Ожидает выполнения" , running : "Выполняется" , applied : "Применена" , failed : "Ошибка выполнения" ,
blocked : "Заблокирована" , cancelled : "Отменена" , partial : "Выполнена частично" , unknown : "Исход не подтверждён" ,
}
const stepStates : Record < string , string > = { intent : "Записано намерение" , applied : "Применён" , acknowledged : "Получено подтверждение приёма" , verified : "Результат проверен" }
function OperationMonitor ( { api , id } : { api : McpApi ; id : string } ) {
const recoveryLink = new URLSearchParams ( useSearchParams ( ) )
recoveryLink . delete ( "operation" )
recoveryLink . set ( "reconcile" , id )
const load = useCallback ( ( signal : AbortSignal ) = > api . records ( "operations" , new URLSearchParams ( { operationId : id , limit : "1" } ) , signal ) , [ api , id ] )
const query = useQuery ( load , id )
const operation = query . data ? . items . find ( item = > item . id === id )
const state = String ( operation ? . state ? ? "" )
const steps = Array . isArray ( operation ? . steps ) ? operation . steps as RecordRow [ ] : [ ]
return < >
< ErrorMessage message = { query . error } / >
< div className = "flex flex-wrap items-center gap-2" >
< span className = "break-all font-mono text-xs" > { id } < / span >
< Button variant = "outline" size = "sm" onClick = { query . refresh } > О б н о в и т ь в ы п о л н е н и е < / Button >
< / div >
< p aria-live = "polite" className = "text-sm" >
{ operation ? operationStates [ state ] ? ? state : query.data ? "Операция отсутствует в доступном журнале" : query . error ? "Состояние недоступно" : "Получаем состояние операции…" }
{ query . error && operation && " · последнее полученное состояние" }
< / p >
{ operation && < >
{ ( state === "partial" || state === "unknown" ) && < div className = "flex flex-wrap items-center gap-2 text-sm" >
< p className = "text-muted-foreground" > П е р е д о б р а т н ы м и з м е н е н и е м с в е р ь т е ф а к т и ч е с к о е с о с т о я н и е . П о в т о р м о ж е т п о в т о р н о и з м е н и т ь к о н ф и г у р а ц и ю . < / p >
{ can ( "mm:mcp:admin" ) && < Link className = { buttonVariants ( { variant : "outline" , size : "sm" } ) } href = { ` /mcp? ${ recoveryLink } #mcp-recovery ` } > П е р е й т и к с в е р к е < / Link > }
< / div > }
{ steps . length > 0 && < section aria-label = "Шаги выполнения" className = "rounded-md border p-3 text-sm" >
< h3 className = "font-medium" > Ш а г и в ы п о л н е н и я < / h3 >
< ol className = "mt-2 list-inside list-decimal space-y-1" >
{ steps . map ( step = > < li key = { String ( step . step_index ) } > { stepStates [ String ( step . state ) ] ? ? String ( step . state ) } { step . server_id != null && ` · сервер ${ step . server_id } ` } { step . finished_at != null && ` · ${ date ( step . finished_at ) } ` } < / li > ) }
< / ol >
{ operation . stepsTruncated === true && < p className = "mt-2 text-muted-foreground" > П о к а з а н ы п е р в ы е 100 ш а г о в . < / p > }
< / section > }
< pre className = "max-h-[45dvh] overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-3 text-xs" > { pretty ( operation ) } < / pre >
{ ! query . error && < ArtifactDownloads api = { api } operation = { operation } / > }
< / > }
< / >
}
function RecordFilters ( { fields , value , update } : { fields : readonly string [ ] ; value : ( field : string ) = > string ; update : ( field : string , value : string ) = > void } ) {
function RecordFilters ( { fields , value , update } : { fields : readonly string [ ] ; value : ( field : string ) = > string ; update : ( field : string , value : string ) = > void } ) {
return (
return (
< div className = "grid gap-3 sm:grid-cols-2 lg:grid-cols-3" >
< div className = "grid gap-3 sm:grid-cols-2 lg:grid-cols-3" >
@@ -554,13 +660,27 @@ function Permissions({ api, operations }: { api: McpApi; operations: McpOperatio
< / Panel >
< / Panel >
< / div >
< / div >
}
}
function OperationRecovery ( { api } : { api : McpApi } ) {
function OperationRecovery ( { api , operationId } : { api : McpApi ; operationId : string } ) {
const [ id , setId ] = useState ( "" ) , [ result , setResult ] = useState < unknown > ( null ) , [ error , setError ] = useState ( "" ) , [ busy , setBusy ] = useState ( false )
const [ id , setId ] = useState ( operationId ) , [ result , setResult ] = useState < unknown > ( null ) , [ error , setError ] = useState ( "" ) , [ busy , setBusy ] = useState ( false )
const target = id . trim ( )
const validId = mcpPageQuerySchema . shape . operationId . safeParse ( target ) . success
const run = async ( recover : boolean ) = > {
const run = async ( recover : boolean ) = > {
if ( busy || ! validId ) return
setBusy ( true ) ; setError ( "" ) ; setResult ( null )
setBusy ( true ) ; setError ( "" ) ; setResult ( null )
try { setResult ( recover ? await api . recoverOperation ( id ) : await api . reconcileOperation ( id ) ) } catch ( failure ) { setError ( errorText ( failure ) ) } finally { setBusy ( false ) }
try { setResult ( recover ? await api . recoverOperation ( target ) : await api . reconcileOperation ( target ) ) } 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 >
return < section id = "mcp-recovery" >
< Panel title = "Сверка и восстановление" description = "Сверка читает состояние устройства. Восстановление создаёт отдельный план и проверяет отсутствие внешних изменений." >
< ErrorMessage message = { error } / >
< Field label = "ID исходной операции" > < Input value = { id } disabled = { busy } aria-invalid = { ! ! target && ! validId } onChange = { event = > { setId ( event . target . value ) ; setResult ( null ) ; setError ( "" ) } } / > < / Field >
{ target && ! validId && < p className = "text-sm text-destructive" > У к а ж и т е UUID о п е р а ц и и и з ж у р н а л а . < / p > }
< div className = "flex flex-wrap gap-2" >
< Button variant = "outline" disabled = { busy || ! validId } onClick = { ( ) = > { void run ( false ) } } > С в е р и т ь с о с т о я н и е о п е р а ц и и < / Button >
< Button disabled = { busy || ! validId } 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 >
< / section >
}
}
function ManagementChannels ( { api } : { api : McpApi } ) {
function ManagementChannels ( { api } : { api : McpApi } ) {
const channels = useQuery ( useCallback ( signal = > api . managementChannels ( signal ) , [ api ] ) )
const channels = useQuery ( useCallback ( signal = > api . managementChannels ( signal ) , [ api ] ) )
@@ -646,9 +766,10 @@ function McpWorkspaceContent({ backendUrl, live }: { backendUrl: string; live: b
const params = useSearchParams ( ) , initial = params . get ( "tab" ) as Tab | null
const params = useSearchParams ( ) , initial = params . get ( "tab" ) as Tab | null
const oauthRequestId = params . get ( "oauthRequest" )
const oauthRequestId = params . get ( "oauthRequest" )
const [ tab , setTab ] = useState < Tab > ( initial && initial in tabs ? initial : "overview" )
const [ tab , setTab ] = useState < Tab > ( initial && initial in tabs ? initial : "overview" )
const recoveryId = params . get ( "reconcile" ) ? ? ""
const api = useMemo ( ( ) = > mcpApi ( backendUrl ) , [ backendUrl ] )
const api = useMemo ( ( ) = > mcpApi ( backendUrl ) , [ backendUrl ] )
const [ operations , setOperations ] = useState < McpOperationDescriptor [ ] > ( [ ] ) , [ error , setError ] = useState ( "" )
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 ] )
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 ) }
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 } 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 > < / >
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 key = { recoveryId } api = { api } operationId = { recoveryId } / > < 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 > < / >
}
}