mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
feat(web): parallel health checks and MCP form UX improvements
- Bot overview: fetch check keys first, then parallel-request each key independently with per-item loading spinner - Stable check ordering via key-indexed placeholder array - MCP form: replace flat command/url fields with Stdio/Remote tab switcher for clear mode selection - MCP type labels: "Stdio (Local)" / "Remote (HTTP/SSE)"
This commit is contained in:
@@ -168,7 +168,7 @@
|
||||
"cwdPlaceholder": "Enter working directory path",
|
||||
"env": "Environment",
|
||||
"envPlaceholder": "Format: KEY:VALUE",
|
||||
"active": "Enable Now",
|
||||
"active": "Enabled",
|
||||
"types": {
|
||||
"stdio": "Stdio (Local)",
|
||||
"remote": "Remote (HTTP/SSE)"
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
"cwdPlaceholder": "输入工作目录路径",
|
||||
"env": "环境变量",
|
||||
"envPlaceholder": "格式:KEY:VALUE",
|
||||
"active": "立即启用",
|
||||
"active": "启用",
|
||||
"types": {
|
||||
"stdio": "本地命令 (Stdio)",
|
||||
"remote": "远程服务 (HTTP/SSE)"
|
||||
|
||||
@@ -9,70 +9,62 @@
|
||||
{{ $t('mcp.addDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 shrink-0 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="loading"
|
||||
@click="loadList"
|
||||
>
|
||||
<Spinner
|
||||
v-if="loading"
|
||||
class="mr-1.5"
|
||||
/>
|
||||
{{ $t('common.refresh') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="handleExport"
|
||||
>
|
||||
{{ $t('common.export') }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
{{ $t('common.add') }}
|
||||
</Button>
|
||||
<div class="flex flex-wrap items-center gap-2 shrink-0 justify-end">
|
||||
<template v-if="selectedIds.length === 0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="loading"
|
||||
@click="loadList"
|
||||
>
|
||||
<Spinner
|
||||
v-if="loading"
|
||||
class="mr-1.5"
|
||||
/>
|
||||
{{ $t('common.refresh') }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
{{ $t('common.add') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-sm text-muted-foreground mr-1">
|
||||
{{ $t('common.batchSelected', { count: selectedIds.length }) }}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="clearSelection"
|
||||
>
|
||||
{{ $t('common.cancelSelection') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="handleBatchExport"
|
||||
>
|
||||
{{ $t('common.export') }}
|
||||
</Button>
|
||||
<ConfirmPopover
|
||||
:message="$t('common.batchDeleteConfirm', { count: selectedIds.length })"
|
||||
@confirm="handleBatchDelete"
|
||||
>
|
||||
<template #trigger>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
>
|
||||
{{ $t('common.delete') }}
|
||||
</Button>
|
||||
</template>
|
||||
</ConfirmPopover>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Batch bar -->
|
||||
<div
|
||||
v-if="selectedIds.length > 0"
|
||||
class="flex items-center gap-3 rounded-md border bg-muted/50 px-3 py-2"
|
||||
>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ $t('common.batchSelected', { count: selectedIds.length }) }}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="handleBatchExport"
|
||||
>
|
||||
{{ $t('common.batchExport') }}
|
||||
</Button>
|
||||
<ConfirmPopover
|
||||
:message="$t('common.batchDeleteConfirm', { count: selectedIds.length })"
|
||||
@confirm="handleBatchDelete"
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
>
|
||||
{{ $t('common.batchDelete') }}
|
||||
</Button>
|
||||
</ConfirmPopover>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="clearSelection"
|
||||
>
|
||||
{{ $t('common.cancelSelection') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div
|
||||
v-if="loading && items.length === 0"
|
||||
@@ -99,38 +91,18 @@
|
||||
:data="items"
|
||||
/>
|
||||
|
||||
<!-- Add/Edit dialog: Single form + Import tab with bidirectional sync -->
|
||||
<!-- Add dialog: tabs (single | import). Edit dialog: two columns (form | json) with sync -->
|
||||
<Dialog v-model:open="formDialogOpen">
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogContent :class="editingItem ? 'sm:max-w-4xl max-h-[90vh] flex flex-col w-[calc(100vw-2rem)] max-w-[calc(100vw-2rem)] sm:w-auto sm:max-w-full' : 'sm:max-w-[28rem] w-[calc(100vw-2rem)] max-w-[calc(100vw-2rem)] sm:w-auto'">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ editingItem ? $t('common.edit') : $t('common.add') }} MCP Server</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs
|
||||
v-model="addDialogTab"
|
||||
class="mt-4 w-full"
|
||||
>
|
||||
<TabsList class="w-full">
|
||||
<TabsTrigger
|
||||
value="single"
|
||||
:disabled="!!editingItem"
|
||||
>
|
||||
{{ $t('common.tabAddSingle') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="import"
|
||||
:disabled="!!editingItem"
|
||||
>
|
||||
{{ $t('common.tabImportJson') }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value="single"
|
||||
class="mt-3"
|
||||
>
|
||||
<!-- Edit: two columns on desktop, stacked on mobile -->
|
||||
<template v-if="editingItem">
|
||||
<div class="mt-3 flex flex-col md:grid md:grid-cols-2 gap-4 flex-1 min-h-0 overflow-y-auto">
|
||||
<form
|
||||
class="flex flex-col gap-3"
|
||||
class="flex flex-col gap-3 min-h-0 rounded-lg border border-border bg-card p-3 md:bg-transparent md:border-0 md:p-0 md:rounded-none md:overflow-y-auto md:pr-2"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
@@ -138,9 +110,9 @@
|
||||
<Input
|
||||
v-model="formData.name"
|
||||
:placeholder="$t('common.namePlaceholder')"
|
||||
@update:model-value="syncFormToEditJson"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
v-model="connectionMode"
|
||||
class="w-full"
|
||||
@@ -153,7 +125,6 @@
|
||||
{{ $t('mcp.types.remote') }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value="stdio"
|
||||
class="mt-3 flex flex-col gap-3"
|
||||
@@ -163,15 +134,16 @@
|
||||
<Input
|
||||
v-model="formData.command"
|
||||
:placeholder="$t('mcp.commandPlaceholder')"
|
||||
@update:model-value="syncFormToEditJson"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{{ $t('mcp.arguments') }}</Label>
|
||||
<TagsInput
|
||||
v-model="argsTags"
|
||||
:add-on-blur="true"
|
||||
:duplicate="true"
|
||||
@update:model-value="syncFormToEditJson"
|
||||
>
|
||||
<TagsInputItem
|
||||
v-for="item in argsTags"
|
||||
@@ -187,14 +159,13 @@
|
||||
/>
|
||||
</TagsInput>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{{ $t('mcp.env') }}</Label>
|
||||
<TagsInput
|
||||
:model-value="envTags.tagList.value"
|
||||
:add-on-blur="true"
|
||||
:convert-value="envTags.convertValue"
|
||||
@update:model-value="(tags) => envTags.handleUpdate(tags.map(String))"
|
||||
@update:model-value="(tags) => { envTags.handleUpdate(tags.map(String)); syncFormToEditJson() }"
|
||||
>
|
||||
<TagsInputItem
|
||||
v-for="(value, index) in envTags.tagList.value"
|
||||
@@ -210,16 +181,15 @@
|
||||
/>
|
||||
</TagsInput>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{{ $t('mcp.cwd') }}</Label>
|
||||
<Input
|
||||
v-model="formData.cwd"
|
||||
:placeholder="$t('mcp.cwdPlaceholder')"
|
||||
@update:model-value="syncFormToEditJson"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="remote"
|
||||
class="mt-3 flex flex-col gap-3"
|
||||
@@ -229,16 +199,16 @@
|
||||
<Input
|
||||
v-model="formData.url"
|
||||
placeholder="https://example.com/mcp"
|
||||
@update:model-value="syncFormToEditJson"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>Headers</Label>
|
||||
<TagsInput
|
||||
:model-value="headerTags.tagList.value"
|
||||
:add-on-blur="true"
|
||||
:convert-value="headerTags.convertValue"
|
||||
@update:model-value="(tags) => headerTags.handleUpdate(tags.map(String))"
|
||||
@update:model-value="(tags) => { headerTags.handleUpdate(tags.map(String)); syncFormToEditJson() }"
|
||||
>
|
||||
<TagsInputItem
|
||||
v-for="(value, index) in headerTags.tagList.value"
|
||||
@@ -254,10 +224,12 @@
|
||||
/>
|
||||
</TagsInput>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>Transport</Label>
|
||||
<Select v-model="formData.transport">
|
||||
<Select
|
||||
v-model="formData.transport"
|
||||
@update:model-value="syncFormToEditJson"
|
||||
>
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="http" />
|
||||
</SelectTrigger>
|
||||
@@ -275,12 +247,243 @@
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</form>
|
||||
<div class="flex flex-col min-h-0 rounded-lg border border-border bg-card p-3 md:bg-transparent md:border-0 md:p-0 md:rounded-none">
|
||||
<Label class="text-sm mb-1">JSON</Label>
|
||||
<Textarea
|
||||
v-model="editJson"
|
||||
class="font-mono text-xs flex-1 min-h-[180px] md:min-h-[200px]"
|
||||
@update:model-value="syncEditJsonToForm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter class="mt-4 flex-shrink-0 flex-row flex-wrap items-center gap-2 sm:justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-sm font-normal">{{ $t('mcp.active') }}</Label>
|
||||
<Switch
|
||||
:model-value="formData.active"
|
||||
@update:model-value="(val) => (formData.active = !!val)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<DialogClose as-child>
|
||||
<Button variant="outline">
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
:disabled="submitting || !formData.name.trim() || (connectionMode === 'stdio' ? !formData.command.trim() : !formData.url.trim())"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<Spinner
|
||||
v-if="submitting"
|
||||
class="mr-1.5"
|
||||
/>
|
||||
{{ $t('common.confirm') }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<Label>{{ $t('mcp.active') }}</Label>
|
||||
<Switch v-model:checked="formData.active" />
|
||||
</div>
|
||||
<!-- Add: tabs single | import -->
|
||||
<template v-else>
|
||||
<Tabs
|
||||
v-model="addDialogTab"
|
||||
class="mt-4 w-full"
|
||||
>
|
||||
<TabsList class="w-full">
|
||||
<TabsTrigger value="single">
|
||||
{{ $t('common.tabAddSingle') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="import">
|
||||
{{ $t('common.tabImportJson') }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value="single"
|
||||
class="mt-3"
|
||||
>
|
||||
<form
|
||||
class="flex flex-col gap-3"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label>{{ $t('common.name') }}</Label>
|
||||
<Input
|
||||
v-model="formData.name"
|
||||
:placeholder="$t('common.namePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<Tabs
|
||||
v-model="connectionMode"
|
||||
class="w-full"
|
||||
>
|
||||
<TabsList class="w-full">
|
||||
<TabsTrigger value="stdio">
|
||||
{{ $t('mcp.types.stdio') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="remote">
|
||||
{{ $t('mcp.types.remote') }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
value="stdio"
|
||||
class="mt-3 flex flex-col gap-3"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label>{{ $t('mcp.command') }}</Label>
|
||||
<Input
|
||||
v-model="formData.command"
|
||||
:placeholder="$t('mcp.commandPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>{{ $t('mcp.arguments') }}</Label>
|
||||
<TagsInput
|
||||
v-model="argsTags"
|
||||
:add-on-blur="true"
|
||||
:duplicate="true"
|
||||
>
|
||||
<TagsInputItem
|
||||
v-for="item in argsTags"
|
||||
:key="item"
|
||||
:value="item"
|
||||
>
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput
|
||||
:placeholder="$t('mcp.argumentsPlaceholder')"
|
||||
class="w-full py-1"
|
||||
/>
|
||||
</TagsInput>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>{{ $t('mcp.env') }}</Label>
|
||||
<TagsInput
|
||||
:model-value="envTags.tagList.value"
|
||||
:add-on-blur="true"
|
||||
:convert-value="envTags.convertValue"
|
||||
@update:model-value="(tags) => envTags.handleUpdate(tags.map(String))"
|
||||
>
|
||||
<TagsInputItem
|
||||
v-for="(value, index) in envTags.tagList.value"
|
||||
:key="index"
|
||||
:value="value"
|
||||
>
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput
|
||||
:placeholder="$t('mcp.envPlaceholder')"
|
||||
class="w-full py-1"
|
||||
/>
|
||||
</TagsInput>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>{{ $t('mcp.cwd') }}</Label>
|
||||
<Input
|
||||
v-model="formData.cwd"
|
||||
:placeholder="$t('mcp.cwdPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="remote"
|
||||
class="mt-3 flex flex-col gap-3"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label>URL</Label>
|
||||
<Input
|
||||
v-model="formData.url"
|
||||
placeholder="https://example.com/mcp"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>Headers</Label>
|
||||
<TagsInput
|
||||
:model-value="headerTags.tagList.value"
|
||||
:add-on-blur="true"
|
||||
:convert-value="headerTags.convertValue"
|
||||
@update:model-value="(tags) => headerTags.handleUpdate(tags.map(String))"
|
||||
>
|
||||
<TagsInputItem
|
||||
v-for="(value, index) in headerTags.tagList.value"
|
||||
:key="index"
|
||||
:value="value"
|
||||
>
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput
|
||||
placeholder="Key:Value"
|
||||
class="w-full py-1"
|
||||
/>
|
||||
</TagsInput>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>Transport</Label>
|
||||
<Select v-model="formData.transport">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="http" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="http">
|
||||
HTTP (Streamable)
|
||||
</SelectItem>
|
||||
<SelectItem value="sse">
|
||||
SSE
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<DialogFooter class="mt-4 flex-row flex-wrap items-center gap-2 sm:justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-sm font-normal">{{ $t('mcp.active') }}</Label>
|
||||
<Switch
|
||||
:model-value="formData.active"
|
||||
@update:model-value="(val) => (formData.active = !!val)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<DialogClose as-child>
|
||||
<Button variant="outline">
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="submitting || !formData.name.trim() || (connectionMode === 'stdio' ? !formData.command.trim() : !formData.url.trim())"
|
||||
>
|
||||
<Spinner
|
||||
v-if="submitting"
|
||||
class="mr-1.5"
|
||||
/>
|
||||
{{ $t('common.confirm') }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="import"
|
||||
class="mt-3 space-y-3"
|
||||
>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('mcp.importHint') }}
|
||||
</p>
|
||||
<Textarea
|
||||
v-model="importJson"
|
||||
rows="10"
|
||||
class="font-mono text-xs"
|
||||
:placeholder="importJsonPlaceholder"
|
||||
/>
|
||||
<DialogFooter class="mt-4">
|
||||
<DialogClose as-child>
|
||||
<Button variant="outline">
|
||||
@@ -288,59 +491,25 @@
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="submitting || !formData.name.trim() || (connectionMode === 'stdio' ? !formData.command.trim() : !formData.url.trim())"
|
||||
:disabled="importSubmitting || !importJson.trim()"
|
||||
@click="handleImport"
|
||||
>
|
||||
<Spinner
|
||||
v-if="submitting"
|
||||
v-if="importSubmitting"
|
||||
class="mr-1.5"
|
||||
/>
|
||||
{{ $t('common.confirm') }}
|
||||
{{ $t('common.import') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="import"
|
||||
class="mt-3 space-y-3"
|
||||
>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('mcp.importHint') }}
|
||||
</p>
|
||||
<Textarea
|
||||
v-model="importJson"
|
||||
rows="10"
|
||||
class="font-mono text-xs"
|
||||
:placeholder="importJsonPlaceholder"
|
||||
/>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
:disabled="importSubmitting || !importJson.trim()"
|
||||
@click="handleImport"
|
||||
>
|
||||
<Spinner
|
||||
v-if="importSubmitting"
|
||||
class="mr-1.5"
|
||||
/>
|
||||
{{ $t('common.import') }}
|
||||
</Button>
|
||||
</div>
|
||||
<DialogFooter class="mt-4">
|
||||
<DialogClose as-child>
|
||||
<Button variant="outline">
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</template>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Export dialog -->
|
||||
<Dialog v-model:open="exportDialogOpen">
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogContent class="sm:max-w-lg w-[calc(100vw-2rem)] max-w-[calc(100vw-2rem)] sm:w-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t('common.export') }} mcpServers</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -378,7 +547,6 @@ import { type ColumnDef } from '@tanstack/vue-table'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
@@ -442,7 +610,7 @@ const importJsonPlaceholder = `{
|
||||
"mcpServers": {
|
||||
"hello": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-hello-world"]
|
||||
"args": ["-y", "mcp-hello-world"]
|
||||
}
|
||||
}
|
||||
}`
|
||||
@@ -463,6 +631,10 @@ const formData = ref({
|
||||
active: true,
|
||||
})
|
||||
|
||||
// Edit dialog: JSON panel synced with formData
|
||||
const editJson = ref('')
|
||||
let editSyncFromJson = false
|
||||
|
||||
watch(connectionMode, (mode) => {
|
||||
if (mode === 'stdio') {
|
||||
formData.value.url = ''
|
||||
@@ -545,19 +717,28 @@ const columns = computed<ColumnDef<McpItem>[]>(() => [
|
||||
id: 'select',
|
||||
header: () =>
|
||||
h('div', { class: 'flex items-center justify-center py-4' }, [
|
||||
h(Checkbox, {
|
||||
h('input', {
|
||||
type: 'checkbox',
|
||||
class: 'size-4 cursor-pointer rounded border border-input',
|
||||
checked: isAllSelected.value,
|
||||
'onUpdate:checked': (v: boolean | 'indeterminate') => toggleSelectAll(v === true),
|
||||
onChange: (e: Event) => {
|
||||
toggleSelectAll((e.target as HTMLInputElement).checked)
|
||||
},
|
||||
}),
|
||||
]),
|
||||
cell: ({ row }) =>
|
||||
h('div', { class: 'flex justify-center' }, [
|
||||
h(Checkbox, {
|
||||
checked: selectedIds.value.includes(row.original.id),
|
||||
'onUpdate:checked': (v: boolean | 'indeterminate') =>
|
||||
toggleSelection(row.original.id, v === true),
|
||||
cell: ({ row }) => {
|
||||
const id = row.original.id
|
||||
return h('div', { class: 'flex justify-center' }, [
|
||||
h('input', {
|
||||
type: 'checkbox',
|
||||
class: 'size-4 cursor-pointer rounded border border-input',
|
||||
checked: selectedIds.value.includes(id),
|
||||
onChange: (e: Event) => {
|
||||
toggleSelection(id, (e.target as HTMLInputElement).checked)
|
||||
},
|
||||
}),
|
||||
]),
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
@@ -573,9 +754,17 @@ const columns = computed<ColumnDef<McpItem>[]>(() => [
|
||||
header: () => h('div', { class: 'text-left' }, 'Command / URL'),
|
||||
cell: ({ row }) => {
|
||||
const cfg = row.original.config ?? {}
|
||||
return h('span', { class: 'font-mono text-xs' },
|
||||
configValue(cfg, 'command') || configValue(cfg, 'url') || '-',
|
||||
)
|
||||
const cmd = configValue(cfg, 'command')
|
||||
const url = configValue(cfg, 'url')
|
||||
const args = configArray(cfg, 'args')
|
||||
const full =
|
||||
cmd
|
||||
? (args.length ? `${cmd} ${args.join(' ')}` : cmd)
|
||||
: (url || '-')
|
||||
return h('span', {
|
||||
class: 'font-mono text-xs block max-w-[280px] truncate',
|
||||
title: full,
|
||||
}, full)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -637,7 +826,6 @@ function openCreateDialog() {
|
||||
|
||||
function openEditDialog(item: McpItem) {
|
||||
editingItem.value = item
|
||||
addDialogTab.value = 'single'
|
||||
const cfg = item.config ?? {}
|
||||
connectionMode.value = item.type === 'stdio' ? 'stdio' : 'remote'
|
||||
formData.value = {
|
||||
@@ -646,14 +834,116 @@ function openEditDialog(item: McpItem) {
|
||||
url: configValue(cfg, 'url'),
|
||||
cwd: configValue(cfg, 'cwd'),
|
||||
transport: item.type === 'sse' ? 'sse' : 'http',
|
||||
active: item.is_active,
|
||||
active: !!item.is_active,
|
||||
}
|
||||
argsTags.value = configArray(cfg, 'args')
|
||||
envTags.initFromObject(configMap(cfg, 'env'))
|
||||
headerTags.initFromObject(configMap(cfg, 'headers'))
|
||||
editSyncFromJson = false
|
||||
syncFormToEditJson()
|
||||
formDialogOpen.value = true
|
||||
}
|
||||
|
||||
function buildFormToEntry(): McpServerEntry | null {
|
||||
const d = formData.value
|
||||
const name = d.name.trim()
|
||||
if (!name) return null
|
||||
if (d.command.trim()) {
|
||||
const entry: McpServerEntry = {
|
||||
command: d.command.trim(),
|
||||
args: argsTags.value.length ? argsTags.value : undefined,
|
||||
cwd: d.cwd.trim() || undefined,
|
||||
}
|
||||
const env: Record<string, string> = {}
|
||||
envTags.tagList.value.forEach((tag) => {
|
||||
const [k, v] = tag.split(':')
|
||||
if (k && v) env[k] = v
|
||||
})
|
||||
if (Object.keys(env).length > 0) entry.env = env
|
||||
return entry
|
||||
}
|
||||
if (d.url.trim()) {
|
||||
const entry: McpServerEntry = {
|
||||
url: d.url.trim(),
|
||||
transport: d.transport === 'sse' ? 'sse' : undefined,
|
||||
}
|
||||
const headers: Record<string, string> = {}
|
||||
headerTags.tagList.value.forEach((tag) => {
|
||||
const [k, v] = tag.split(':')
|
||||
if (k && v) headers[k] = v
|
||||
})
|
||||
if (Object.keys(headers).length > 0) entry.headers = headers
|
||||
return entry
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function syncFormToEditJson() {
|
||||
if (editSyncFromJson) return
|
||||
const entry = buildFormToEntry()
|
||||
if (!entry) {
|
||||
editJson.value = ''
|
||||
return
|
||||
}
|
||||
const name = formData.value.name.trim()
|
||||
const mcpServers: Record<string, McpServerEntry> = { [name]: entry }
|
||||
editJson.value = JSON.stringify({ mcpServers }, null, 2)
|
||||
}
|
||||
|
||||
function syncEditJsonToForm() {
|
||||
const raw = editJson.value.trim()
|
||||
if (!raw) return
|
||||
editSyncFromJson = true
|
||||
try {
|
||||
let parsed: { mcpServers?: Record<string, McpServerEntry> } = JSON.parse(raw)
|
||||
if (!parsed.mcpServers && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
parsed = { mcpServers: parsed as Record<string, McpServerEntry> }
|
||||
}
|
||||
const servers = parsed.mcpServers
|
||||
if (!servers || typeof servers !== 'object') {
|
||||
editSyncFromJson = false
|
||||
return
|
||||
}
|
||||
const entries = Object.entries(servers)
|
||||
const single = entries.length === 1 ? entries[0] : null
|
||||
if (!single) {
|
||||
editSyncFromJson = false
|
||||
return
|
||||
}
|
||||
const [name, e] = single
|
||||
if (e.command) {
|
||||
connectionMode.value = 'stdio'
|
||||
formData.value = {
|
||||
name,
|
||||
command: e.command ?? '',
|
||||
url: '',
|
||||
cwd: e.cwd ?? '',
|
||||
transport: 'http',
|
||||
active: formData.value.active,
|
||||
}
|
||||
argsTags.value = e.args ?? []
|
||||
envTags.initFromObject(e.env ?? null)
|
||||
headerTags.initFromObject(null)
|
||||
} else if (e.url) {
|
||||
connectionMode.value = 'remote'
|
||||
formData.value = {
|
||||
name,
|
||||
command: '',
|
||||
url: e.url ?? '',
|
||||
cwd: '',
|
||||
transport: e.transport === 'sse' ? 'sse' : 'http',
|
||||
active: formData.value.active,
|
||||
}
|
||||
argsTags.value = []
|
||||
envTags.initFromObject(null)
|
||||
headerTags.initFromObject(e.headers ?? null)
|
||||
}
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
editSyncFromJson = false
|
||||
}
|
||||
|
||||
function buildRequestBody() {
|
||||
const body: Record<string, unknown> = {
|
||||
name: formData.value.name.trim(),
|
||||
@@ -773,19 +1063,6 @@ async function handleImport() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
const { data } = await client.get({
|
||||
url: `/bots/${props.botId}/mcp-ops/export`,
|
||||
throwOnError: true,
|
||||
}) as { data: { mcpServers: Record<string, unknown> } }
|
||||
exportJson.value = JSON.stringify(data, null, 2)
|
||||
exportDialogOpen.value = true
|
||||
} catch (error) {
|
||||
toast.error(resolveError(error, t('mcp.exportFailed')))
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyExport() {
|
||||
navigator.clipboard.writeText(exportJson.value)
|
||||
toast.success(t('common.copied'))
|
||||
|
||||
@@ -57,16 +57,17 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Allow Guest -->
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>{{ $t('bots.settings.allowGuest') }}</Label>
|
||||
<Switch
|
||||
:model-value="form.allow_guest"
|
||||
@update:model-value="(val) => form.allow_guest = !!val"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<!-- Allow Guest: only for public bot -->
|
||||
<template v-if="isPublicBot">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>{{ $t('bots.settings.allowGuest') }}</Label>
|
||||
<Switch
|
||||
:model-value="form.allow_guest"
|
||||
@update:model-value="(val) => form.allow_guest = !!val"
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
</template>
|
||||
|
||||
<!-- Save -->
|
||||
<div class="flex justify-end">
|
||||
@@ -135,8 +136,11 @@ import type { Ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
botId: string
|
||||
botType?: string
|
||||
}>()
|
||||
|
||||
const isPublicBot = computed(() => props.botType === 'public')
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -220,14 +224,16 @@ watch(settings, (val) => {
|
||||
const hasChanges = computed(() => {
|
||||
if (!settings.value) return true
|
||||
const s = settings.value
|
||||
return (
|
||||
let changed =
|
||||
form.chat_model_id !== (s.chat_model_id ?? '')
|
||||
|| form.memory_model_id !== (s.memory_model_id ?? '')
|
||||
|| form.embedding_model_id !== (s.embedding_model_id ?? '')
|
||||
|| form.max_context_load_time !== (s.max_context_load_time ?? 0)
|
||||
|| form.language !== (s.language ?? '')
|
||||
|| form.allow_guest !== (s.allow_guest ?? false)
|
||||
)
|
||||
if (isPublicBot.value) {
|
||||
changed = changed || form.allow_guest !== (s.allow_guest ?? false)
|
||||
}
|
||||
return changed
|
||||
})
|
||||
|
||||
async function handleSave() {
|
||||
|
||||
@@ -191,20 +191,32 @@
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="font-mono text-xs">{{ checkKeyLabel(item.check_key) }}</p>
|
||||
<div v-if="isCheckLoading(item)">
|
||||
<Spinner class="size-3.5" />
|
||||
</div>
|
||||
<Badge
|
||||
v-else
|
||||
:variant="checkStatusVariant(item.status)"
|
||||
class="text-[10px]"
|
||||
>
|
||||
{{ checkStatusLabel(item.status) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="mt-2 text-sm">{{ item.summary }}</p>
|
||||
<p
|
||||
v-if="item.detail"
|
||||
class="mt-1 text-xs text-muted-foreground break-all"
|
||||
v-if="isCheckLoading(item)"
|
||||
class="mt-2 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ item.detail }}
|
||||
{{ $t('common.loading') }}
|
||||
</p>
|
||||
<template v-else>
|
||||
<p class="mt-2 text-sm">{{ item.summary }}</p>
|
||||
<p
|
||||
v-if="item.detail"
|
||||
class="mt-1 text-xs text-muted-foreground break-all"
|
||||
>
|
||||
{{ item.detail }}
|
||||
</p>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -452,7 +464,10 @@
|
||||
value="settings"
|
||||
class="mt-6"
|
||||
>
|
||||
<BotSettings :bot-id="botId" />
|
||||
<BotSettings
|
||||
:bot-id="botId"
|
||||
:bot-type="bot?.type"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -538,11 +553,11 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
|
||||
import {
|
||||
getBotsById, putBotsById,
|
||||
getBotsByIdChecks,
|
||||
getBotsByBotIdContainer, postBotsByBotIdContainer, deleteBotsByBotIdContainer,
|
||||
postBotsByBotIdContainerStart, postBotsByBotIdContainerStop,
|
||||
getBotsByBotIdContainerSnapshots, postBotsByBotIdContainerSnapshots,
|
||||
} from '@memoh/sdk'
|
||||
import { client } from '@memoh/sdk/client'
|
||||
import type {
|
||||
BotsBotCheck, HandlersGetContainerResponse,
|
||||
HandlersListSnapshotsResponse,
|
||||
@@ -581,9 +596,20 @@ const { mutateAsync: updateBot, isLoading: updateBotLoading } = useMutation({
|
||||
},
|
||||
})
|
||||
|
||||
async function fetchBotChecks(id: string): Promise<BotCheck[]> {
|
||||
const { data } = await getBotsByIdChecks({ path: { id }, throwOnError: true })
|
||||
return data.items ?? []
|
||||
async function fetchCheckKeys(id: string): Promise<string[]> {
|
||||
const { data } = await client.get({
|
||||
url: `/bots/${id}/checks/keys`,
|
||||
throwOnError: true,
|
||||
}) as { data: { keys: string[] } }
|
||||
return data.keys ?? []
|
||||
}
|
||||
|
||||
async function fetchSingleCheck(id: string, key: string): Promise<BotCheck> {
|
||||
const { data } = await client.get({
|
||||
url: `/bots/${id}/checks/run/${key}`,
|
||||
throwOnError: true,
|
||||
}) as { data: BotCheck }
|
||||
return data
|
||||
}
|
||||
|
||||
// Replace breadcrumb bot id with display name when available.
|
||||
@@ -830,6 +856,10 @@ function checkStatusLabel(status: BotCheck['status']): string {
|
||||
return t('bots.checks.status.ok')
|
||||
}
|
||||
|
||||
function isCheckLoading(item: BotCheck): boolean {
|
||||
return item.status === 'unknown' && !item.summary
|
||||
}
|
||||
|
||||
function checkKeyLabel(checkKey: string): string {
|
||||
const key = checkKeyI18nKeys[checkKey]
|
||||
if (!key) {
|
||||
@@ -840,10 +870,43 @@ function checkKeyLabel(checkKey: string): string {
|
||||
|
||||
async function loadChecks(showToast: boolean) {
|
||||
checksLoading.value = true
|
||||
checks.value = []
|
||||
try {
|
||||
checks.value = await fetchBotChecks(botId.value)
|
||||
const keys = await fetchCheckKeys(botId.value)
|
||||
if (keys.length === 0) return
|
||||
|
||||
// Maintain key order: pre-fill placeholders, replace as results arrive.
|
||||
const keyOrder = new Map(keys.map((k, i) => [k, i]))
|
||||
checks.value = keys.map((key) => ({
|
||||
check_key: key,
|
||||
status: 'unknown' as BotCheck['status'],
|
||||
summary: '',
|
||||
}))
|
||||
|
||||
const pending = keys.map(async (key) => {
|
||||
try {
|
||||
const result = await fetchSingleCheck(botId.value, key)
|
||||
const idx = keyOrder.get(key)
|
||||
if (idx !== undefined) {
|
||||
const updated = [...checks.value]
|
||||
updated[idx] = result
|
||||
checks.value = updated
|
||||
}
|
||||
} catch {
|
||||
const idx = keyOrder.get(key)
|
||||
if (idx !== undefined) {
|
||||
const updated = [...checks.value]
|
||||
updated[idx] = {
|
||||
check_key: key,
|
||||
status: 'error' as BotCheck['status'],
|
||||
summary: 'Check failed',
|
||||
}
|
||||
checks.value = updated
|
||||
}
|
||||
}
|
||||
})
|
||||
await Promise.all(pending)
|
||||
} catch (error) {
|
||||
checks.value = []
|
||||
if (showToast) {
|
||||
toast.error(resolveErrorMessage(error, t('bots.checks.loadFailed')))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user